wiki-plugin-salon 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.
@@ -0,0 +1,1469 @@
1
+ (function() {
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ // ── Storage paths ─────────────────────────────────────────────────────────────
9
+
10
+ const SALON_DIR = path.join(process.env.HOME || '/root', '.salon');
11
+ const CONFIG_FILE = path.join(SALON_DIR, 'config.json');
12
+ const TENANTS_FILE = path.join(SALON_DIR, 'tenants.json');
13
+ const EVENTS_FILE = path.join(SALON_DIR, 'events.json');
14
+
15
+ const MAX_EVENTS = 500;
16
+
17
+ // ── Config ────────────────────────────────────────────────────────────────────
18
+
19
+ const DEFAULT_CONFIG = {
20
+ registrationMode: 'open', // 'open' | 'closed' | 'grant'
21
+ title: 'The Salon',
22
+ description: 'A community gathering space on this wiki.',
23
+ fromEmail: 'salon@planetnine.app',
24
+ minnieHost: 'localhost',
25
+ minniePort: 2525,
26
+ joanUrl: 'https://dev.allyabase.com/plugin/allyabase/joan'
27
+ };
28
+
29
+ function loadConfig() {
30
+ try {
31
+ if (fs.existsSync(CONFIG_FILE)) {
32
+ return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) };
33
+ }
34
+ } catch {}
35
+ return { ...DEFAULT_CONFIG };
36
+ }
37
+
38
+ function saveConfig(config) {
39
+ ensureDir();
40
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
41
+ }
42
+
43
+ // ── Tenants ───────────────────────────────────────────────────────────────────
44
+
45
+ function loadTenants() {
46
+ try {
47
+ if (fs.existsSync(TENANTS_FILE)) return JSON.parse(fs.readFileSync(TENANTS_FILE, 'utf8'));
48
+ } catch {}
49
+ return {};
50
+ }
51
+
52
+ function saveTenants(tenants) {
53
+ ensureDir();
54
+ fs.writeFileSync(TENANTS_FILE, JSON.stringify(tenants, null, 2));
55
+ }
56
+
57
+ // ── Events ────────────────────────────────────────────────────────────────────
58
+
59
+ function loadEvents() {
60
+ try {
61
+ if (fs.existsSync(EVENTS_FILE)) return JSON.parse(fs.readFileSync(EVENTS_FILE, 'utf8'));
62
+ } catch {}
63
+ return [];
64
+ }
65
+
66
+ function appendEvent(event) {
67
+ ensureDir();
68
+ const events = loadEvents();
69
+ events.unshift({ ...event, receivedAt: Date.now() });
70
+ if (events.length > MAX_EVENTS) events.length = MAX_EVENTS;
71
+ fs.writeFileSync(EVENTS_FILE, JSON.stringify(events, null, 2));
72
+
73
+ // Fire-and-forget email notifications to all active members
74
+ const config = loadConfig();
75
+ const eventDetail = event.data && event.data.title ? ` — ${event.data.title}` : '';
76
+ const subject = `[${config.title}] ${event.source}: ${event.event}${eventDetail}`;
77
+ const text = buildEventEmail(config, event, eventDetail);
78
+ notifyMembers(config, subject, text).catch(() => {});
79
+ }
80
+
81
+ function buildEventEmail(config, event, detail) {
82
+ return [
83
+ `New activity in ${config.title}:`,
84
+ ``,
85
+ `${event.source}: ${event.event}${detail}`,
86
+ ``,
87
+ `Visit the salon: /plugin/salon`,
88
+ ``,
89
+ `—`,
90
+ `To manage your membership visit /plugin/salon/recover`
91
+ ].join('\n');
92
+ }
93
+
94
+ // ── Email ─────────────────────────────────────────────────────────────────────
95
+
96
+ function createTransporter(config) {
97
+ const nodemailer = require('nodemailer');
98
+ return nodemailer.createTransport({
99
+ host: config.minnieHost || 'localhost',
100
+ port: parseInt(config.minniePort) || 2525,
101
+ secure: false,
102
+ tls: { rejectUnauthorized: false }
103
+ });
104
+ }
105
+
106
+ async function notifyMembers(config, subject, text) {
107
+ const tenants = loadTenants();
108
+ const recipients = Object.values(tenants).filter(t => t.status === 'active' && t.email);
109
+ if (recipients.length === 0) return;
110
+
111
+ const transporter = createTransporter(config);
112
+ const from = `"${config.title}" <${config.fromEmail || 'salon@planetnine.app'}>`;
113
+
114
+ for (const tenant of recipients) {
115
+ try {
116
+ await transporter.sendMail({ from, to: tenant.email, subject, text });
117
+ } catch (err) {
118
+ console.error(`salon: email failed for ${tenant.email}:`, err.message);
119
+ }
120
+ }
121
+ }
122
+
123
+ // ── Joan OTP ──────────────────────────────────────────────────────────────────
124
+
125
+ function joanPost(joanUrl, pathname, body) {
126
+ return new Promise((resolve, reject) => {
127
+ const u = new URL(pathname, joanUrl);
128
+ const payload = JSON.stringify(body);
129
+ const lib = u.protocol === 'https:' ? require('https') : require('http');
130
+ const req = lib.request({
131
+ hostname: u.hostname,
132
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
133
+ path: u.pathname,
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ 'Content-Length': Buffer.byteLength(payload)
138
+ }
139
+ }, (res) => {
140
+ let data = '';
141
+ res.on('data', chunk => data += chunk);
142
+ res.on('end', () => {
143
+ try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
144
+ catch { reject(new Error('Invalid JSON from Joan')); }
145
+ });
146
+ });
147
+ req.on('error', reject);
148
+ req.write(payload);
149
+ req.end();
150
+ });
151
+ }
152
+
153
+ // ── Helpers ───────────────────────────────────────────────────────────────────
154
+
155
+ function ensureDir() {
156
+ if (!fs.existsSync(SALON_DIR)) fs.mkdirSync(SALON_DIR, { recursive: true });
157
+ }
158
+
159
+ function generateId() {
160
+ return crypto.randomBytes(8).toString('hex');
161
+ }
162
+
163
+ function hashEmail(email) {
164
+ return crypto.createHash('sha256').update(email.toLowerCase().trim()).digest('hex');
165
+ }
166
+
167
+ function findTenantByEmailHash(tenants, hash) {
168
+ return Object.values(tenants).find(t => t.emailHash === hash) || null;
169
+ }
170
+
171
+ function esc(str) {
172
+ return String(str)
173
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
174
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
175
+ }
176
+
177
+ function timeAgo(ms) {
178
+ const s = Math.floor((Date.now() - ms) / 1000);
179
+ if (s < 60) return `${s}s ago`;
180
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
181
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
182
+ return `${Math.floor(s / 86400)}d ago`;
183
+ }
184
+
185
+ // ── HTML pages ────────────────────────────────────────────────────────────────
186
+
187
+ const STYLE = `
188
+ <meta charset="UTF-8">
189
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
190
+ <style>
191
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
192
+ body { font-family: system-ui, sans-serif; background: #0f0f12; color: #e8e8ea; min-height: 100vh; }
193
+ .topbar { background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; }
194
+ .topbar-title { font-size: 1.1rem; font-weight: 700; color: #c4b5fd; }
195
+ .topbar-links a { color: #888; text-decoration: none; font-size: 0.85rem; margin-left: 16px; }
196
+ .topbar-links a:hover { color: #c4b5fd; }
197
+ .container { max-width: 860px; margin: 0 auto; padding: 40px 24px; }
198
+ h1 { font-size: 2rem; font-weight: 800; color: #c4b5fd; margin-bottom: 8px; }
199
+ .subtitle { color: #888; margin-bottom: 32px; font-size: 0.95rem; line-height: 1.5; }
200
+ .section { margin-bottom: 40px; }
201
+ .section-heading { font-size: 0.75rem; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
202
+ .card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; margin-bottom: 12px; }
203
+ .card:hover { border-color: rgba(196,181,253,0.3); }
204
+ .card-name { font-size: 1rem; font-weight: 600; color: #e8e8ea; margin-bottom: 4px; }
205
+ .card-desc { font-size: 0.85rem; color: #888; line-height: 1.4; }
206
+ .card-meta { font-size: 0.78rem; color: #555; margin-top: 8px; }
207
+ .badge { display: inline-block; font-size: 0.75rem; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
208
+ .badge-open { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
209
+ .badge-closed { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
210
+ .badge-grant { background: rgba(251,191,36,0.15); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); }
211
+ .badge-pending { background: rgba(251,191,36,0.15); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); }
212
+ .badge-active { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
213
+ .badge-denied { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
214
+ .btn { display: inline-block; padding: 10px 24px; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; transition: opacity 0.15s; }
215
+ .btn:hover { opacity: 0.85; }
216
+ .btn-primary { background: #7c3aed; color: white; }
217
+ .btn-secondary { background: rgba(255,255,255,0.08); color: #e8e8ea; border: 1px solid rgba(255,255,255,0.12); }
218
+ .btn-danger { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
219
+ .btn-sm { padding: 5px 12px; font-size: 0.8rem; border-radius: 6px; }
220
+ label { display: block; font-size: 0.82rem; color: #999; margin-bottom: 4px; margin-top: 14px; }
221
+ input, textarea, select { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: #e8e8ea; padding: 10px 12px; font-size: 0.9rem; outline: none; font-family: inherit; transition: border-color 0.2s; }
222
+ input:focus, textarea:focus, select:focus { border-color: #7c3aed; }
223
+ textarea { resize: vertical; }
224
+ .form-hint { font-size: 0.78rem; color: #666; margin-top: 4px; }
225
+ .result { margin-top: 16px; padding: 12px 16px; border-radius: 8px; font-size: 0.9rem; display: none; }
226
+ .result.ok { background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
227
+ .result.err { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
228
+ .empty { text-align: center; padding: 48px 0; color: #555; font-size: 0.9rem; }
229
+ .event-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
230
+ .event-source { font-size: 0.75rem; font-weight: 700; color: #c4b5fd; background: rgba(196,181,253,0.1); border-radius: 4px; padding: 2px 7px; white-space: nowrap; flex-shrink: 0; }
231
+ .event-body { flex: 1; min-width: 0; }
232
+ .event-name { font-size: 0.88rem; color: #e8e8ea; }
233
+ .event-time { font-size: 0.75rem; color: #555; margin-top: 2px; }
234
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
235
+ @media (max-width: 600px) { .grid-2 { grid-template-columns: 1fr; } }
236
+ .divider { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 24px 0; }
237
+ </style>`;
238
+
239
+ function generateSalonPage(config, tenants, events) {
240
+ const modeLabel = { open: 'Open', closed: 'Closed', grant: 'By Application' }[config.registrationMode] || config.registrationMode;
241
+ const canRegister = config.registrationMode !== 'closed';
242
+ const tenantsHtml = tenants.length === 0
243
+ ? '<div class="empty">No members yet. Be the first to join!</div>'
244
+ : tenants.map(t => `
245
+ <a href="/plugin/salon/tenant/${esc(t.uuid)}" style="text-decoration:none;display:block;">
246
+ <div class="card" style="cursor:pointer;">
247
+ <div class="card-name">${esc(t.name)}</div>
248
+ ${t.description ? `<div class="card-desc">${esc(t.description)}</div>` : ''}
249
+ <div class="card-meta">Joined ${timeAgo(t.registeredAt)}</div>
250
+ </div>
251
+ </a>`).join('');
252
+
253
+ const eventsHtml = events.length === 0
254
+ ? '<div class="empty">No events yet.</div>'
255
+ : events.slice(0, 20).map(e => `
256
+ <div class="event-row">
257
+ <span class="event-source">${esc(e.source)}</span>
258
+ <div class="event-body">
259
+ <div class="event-name">${esc(e.event)}${e.data && e.data.title ? ` — ${esc(e.data.title)}` : ''}</div>
260
+ <div class="event-time">${timeAgo(e.receivedAt)}</div>
261
+ </div>
262
+ </div>`).join('');
263
+
264
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>${esc(config.title)}</title></head><body>
265
+ <div class="topbar">
266
+ <span class="topbar-title">✦ ${esc(config.title)}</span>
267
+ <div class="topbar-links">
268
+ <span class="badge badge-${config.registrationMode}">${modeLabel}</span>
269
+ ${canRegister ? '<a href="/plugin/salon/register">Register</a>' : ''}
270
+ <a href="/plugin/salon/recover">My Account</a>
271
+ <a href="/plugin/salon/feed.json">Feed</a>
272
+ </div>
273
+ </div>
274
+ <div class="container">
275
+ <h1>${esc(config.title)}</h1>
276
+ <p class="subtitle">${esc(config.description)}</p>
277
+
278
+ <div class="grid-2">
279
+ <div>
280
+ <div class="section">
281
+ <div class="section-heading">Members (${tenants.length})</div>
282
+ ${tenantsHtml}
283
+ ${canRegister ? `<div style="margin-top:16px;"><a href="/plugin/salon/register" class="btn btn-primary">Join the Salon</a></div>` : ''}
284
+ </div>
285
+ </div>
286
+ <div>
287
+ <div class="section">
288
+ <div class="section-heading">Recent Activity</div>
289
+ ${eventsHtml}
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </body></html>`;
295
+ }
296
+
297
+ function generateRegisterPage(config) {
298
+ const isGrant = config.registrationMode === 'grant';
299
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>Join ${esc(config.title)}</title></head><body>
300
+ <div class="topbar">
301
+ <span class="topbar-title">✦ ${esc(config.title)}</span>
302
+ <div class="topbar-links"><a href="/plugin/salon">← Back to salon</a></div>
303
+ </div>
304
+ <div class="container" style="max-width:520px;">
305
+ <h1>Join the Salon</h1>
306
+ <p class="subtitle">${isGrant
307
+ ? 'Registration is by application. The wiki owner will review your request.'
308
+ : 'Register to become a member of this salon community.'}</p>
309
+
310
+ <form id="reg-form" style="margin-top:8px;">
311
+ <label>Display name *</label>
312
+ <input name="name" type="text" placeholder="Your name" required maxlength="80">
313
+
314
+ <label>Email address *</label>
315
+ <input name="email" type="email" placeholder="you@example.com" required>
316
+ <div class="form-hint">Used to send you updates from the salon. Never shared publicly.</div>
317
+
318
+ <label>About you</label>
319
+ <textarea name="description" rows="3" placeholder="A short bio or description (optional)" maxlength="500"></textarea>
320
+
321
+ <label>Public key (optional)</label>
322
+ <input name="pubKey" type="text" placeholder="secp256k1 public key for verified identity" maxlength="130">
323
+ <div class="form-hint">If you have a Planet Nine wallet, paste your public key here.</div>
324
+
325
+ <div style="margin-top:24px;">
326
+ <button type="submit" class="btn btn-primary">${isGrant ? 'Apply to Join' : 'Join Now'}</button>
327
+ </div>
328
+ <div class="result" id="result"></div>
329
+ </form>
330
+ </div>
331
+ <script>
332
+ document.getElementById('reg-form').addEventListener('submit', async e => {
333
+ e.preventDefault();
334
+ const btn = e.target.querySelector('button[type=submit]');
335
+ const resultEl = document.getElementById('result');
336
+ btn.disabled = true; btn.textContent = 'Submitting…';
337
+ const data = Object.fromEntries(new FormData(e.target));
338
+ try {
339
+ const res = await fetch('/plugin/salon/register', {
340
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify(data)
342
+ });
343
+ const json = await res.json();
344
+ resultEl.className = 'result ' + (json.success ? 'ok' : 'err');
345
+ resultEl.style.display = 'block';
346
+ if (json.success) {
347
+ resultEl.innerHTML = json.status === 'pending'
348
+ ? '✓ Application submitted! The wiki owner will review your request.'
349
+ : \`✓ Welcome! You're now a member. <a href="/plugin/salon/tenant/\${json.uuid}" style="color:#10b981;">View your profile →</a>\`;
350
+ e.target.querySelector('button[type=submit]').style.display = 'none';
351
+ } else {
352
+ resultEl.textContent = json.error || 'Registration failed';
353
+ btn.disabled = false; btn.textContent = '${isGrant ? 'Apply to Join' : 'Join Now'}';
354
+ }
355
+ } catch (err) {
356
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
357
+ resultEl.textContent = err.message;
358
+ btn.disabled = false; btn.textContent = '${isGrant ? 'Apply to Join' : 'Join Now'}';
359
+ }
360
+ });
361
+ </script>
362
+ </body></html>`;
363
+ }
364
+
365
+ function generateClosedPage(config) {
366
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>${esc(config.title)}</title></head><body>
367
+ <div class="topbar">
368
+ <span class="topbar-title">✦ ${esc(config.title)}</span>
369
+ <div class="topbar-links"><a href="/plugin/salon">← Back to salon</a></div>
370
+ </div>
371
+ <div class="container" style="max-width:520px; text-align:center; padding-top: 80px;">
372
+ <div style="font-size:3rem;margin-bottom:16px;">🔒</div>
373
+ <h1 style="font-size:1.5rem;">Registration Closed</h1>
374
+ <p class="subtitle" style="margin-top:8px;">This salon is not currently accepting new members.</p>
375
+ <div style="margin-top:24px;"><a href="/plugin/salon" class="btn btn-secondary">Visit the Salon</a></div>
376
+ </div>
377
+ </body></html>`;
378
+ }
379
+
380
+ function generateTenantPage(tenant) {
381
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>${esc(tenant.name)} — Salon</title></head><body>
382
+ <div class="topbar">
383
+ <span class="topbar-title">✦ Salon</span>
384
+ <div class="topbar-links"><a href="/plugin/salon">← Back to salon</a></div>
385
+ </div>
386
+ <div class="container" style="max-width:600px;">
387
+ <div style="margin-bottom:8px;"><span class="badge badge-active">Active Member</span></div>
388
+ <h1>${esc(tenant.name)}</h1>
389
+ ${tenant.description ? `<p class="subtitle">${esc(tenant.description)}</p>` : ''}
390
+ <div class="card" style="margin-top:24px;">
391
+ <div class="card-meta" style="color:#666;font-size:0.82rem;">
392
+ Member since ${new Date(tenant.registeredAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
393
+ ${tenant.pubKey ? `<br><span style="font-family:monospace;word-break:break-all;">${esc(tenant.pubKey.slice(0,24))}…</span>` : ''}
394
+ </div>
395
+ </div>
396
+ <div style="margin-top:16px;"><a href="/plugin/salon/recover" class="btn btn-secondary btn-sm">Manage my membership</a></div>
397
+ </div>
398
+ </body></html>`;
399
+ }
400
+
401
+ function generateRecoverPage(config, message) {
402
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>My Account — ${esc(config.title)}</title></head><body>
403
+ <div class="topbar">
404
+ <span class="topbar-title">✦ ${esc(config.title)}</span>
405
+ <div class="topbar-links"><a href="/plugin/salon">← Back to salon</a></div>
406
+ </div>
407
+ <div class="container" style="max-width:480px;">
408
+ <h1>My Account</h1>
409
+ <p class="subtitle">Enter your email address to receive a one-time code to manage your membership.</p>
410
+
411
+ ${message ? `<div class="result err" style="display:block;margin-bottom:16px;">${esc(message)}</div>` : ''}
412
+
413
+ <form id="recover-form">
414
+ <label>Email address</label>
415
+ <input name="email" type="email" placeholder="you@example.com" required autofocus>
416
+
417
+ <div style="margin-top:20px;">
418
+ <button type="submit" class="btn btn-primary">Send Code</button>
419
+ </div>
420
+ <div class="result" id="result"></div>
421
+ </form>
422
+ </div>
423
+ <script>
424
+ document.getElementById('recover-form').addEventListener('submit', async e => {
425
+ e.preventDefault();
426
+ const btn = e.target.querySelector('button[type=submit]');
427
+ const resultEl = document.getElementById('result');
428
+ btn.disabled = true; btn.textContent = 'Sending…';
429
+ const data = Object.fromEntries(new FormData(e.target));
430
+ try {
431
+ const res = await fetch('/plugin/salon/recover/send', {
432
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
433
+ body: JSON.stringify(data)
434
+ });
435
+ const json = await res.json();
436
+ resultEl.className = 'result ' + (json.success ? 'ok' : 'err');
437
+ resultEl.style.display = 'block';
438
+ if (json.success) {
439
+ resultEl.innerHTML = '✓ Check your inbox — a code has been sent to <strong>' + data.email + '</strong>.';
440
+ e.target.innerHTML = \`
441
+ <label style="margin-top:16px;">Enter the code from your email</label>
442
+ <input id="otp-code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" placeholder="123456" autofocus style="letter-spacing:0.2em;font-size:1.5rem;text-align:center;">
443
+ <div style="margin-top:16px;">
444
+ <button type="button" id="verify-btn" class="btn btn-primary">Verify</button>
445
+ </div>
446
+ <div class="result" id="verify-result"></div>
447
+ \`;
448
+ document.getElementById('verify-btn').addEventListener('click', async () => {
449
+ const code = document.getElementById('otp-code').value.trim();
450
+ const vbtn = document.getElementById('verify-btn');
451
+ const vresult = document.getElementById('verify-result');
452
+ vbtn.disabled = true; vbtn.textContent = 'Verifying…';
453
+ const vres = await fetch('/plugin/salon/recover/verify', {
454
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
455
+ body: JSON.stringify({ email: data.email, code })
456
+ });
457
+ const vjson = await vres.json();
458
+ if (vjson.success) {
459
+ window.location.href = '/plugin/salon/member/' + vjson.emailHash;
460
+ } else {
461
+ vresult.className = 'result err'; vresult.style.display = 'block';
462
+ vresult.textContent = vjson.error || 'Verification failed';
463
+ vbtn.disabled = false; vbtn.textContent = 'Verify';
464
+ }
465
+ });
466
+ } else {
467
+ resultEl.textContent = json.error || 'Could not send code';
468
+ btn.disabled = false; btn.textContent = 'Send Code';
469
+ }
470
+ } catch (err) {
471
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
472
+ resultEl.textContent = err.message;
473
+ btn.disabled = false; btn.textContent = 'Send Code';
474
+ }
475
+ });
476
+ </script>
477
+ </body></html>`;
478
+ }
479
+
480
+ function generateMemberPage(config, tenant) {
481
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>My Membership — ${esc(config.title)}</title></head><body>
482
+ <div class="topbar">
483
+ <span class="topbar-title">✦ ${esc(config.title)}</span>
484
+ <div class="topbar-links"><a href="/plugin/salon">← Back to salon</a></div>
485
+ </div>
486
+ <div class="container" style="max-width:520px;">
487
+ <div style="margin-bottom:8px;"><span class="badge badge-${tenant.status}">${tenant.status}</span></div>
488
+ <h1>My Membership</h1>
489
+ <p class="subtitle">Update your profile or leave the salon.</p>
490
+
491
+ <div class="card" style="margin-bottom:24px;">
492
+ <form id="update-form">
493
+ <input type="hidden" name="emailHash" value="${esc(tenant.emailHash)}">
494
+
495
+ <label>Display name</label>
496
+ <input name="name" type="text" value="${esc(tenant.name)}" maxlength="80" required>
497
+
498
+ <label>About you</label>
499
+ <textarea name="description" rows="3" maxlength="500">${esc(tenant.description || '')}</textarea>
500
+
501
+ <div style="margin-top:16px;">
502
+ <button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
503
+ </div>
504
+ <div class="result" id="update-result"></div>
505
+ </form>
506
+ </div>
507
+
508
+ <hr class="divider">
509
+
510
+ <div style="margin-top:16px;">
511
+ <p style="color:#888;font-size:0.85rem;margin-bottom:12px;">Leave the salon to remove yourself from the member list and stop receiving updates.</p>
512
+ <button class="btn btn-danger btn-sm" id="leave-btn">Leave the Salon</button>
513
+ <div class="result" id="leave-result"></div>
514
+ </div>
515
+ </div>
516
+
517
+ <script>
518
+ document.getElementById('update-form').addEventListener('submit', async e => {
519
+ e.preventDefault();
520
+ const btn = e.target.querySelector('button[type=submit]');
521
+ const resultEl = document.getElementById('update-result');
522
+ btn.disabled = true; btn.textContent = 'Saving…';
523
+ const data = Object.fromEntries(new FormData(e.target));
524
+ try {
525
+ const res = await fetch('/plugin/salon/member/update', {
526
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
527
+ body: JSON.stringify(data)
528
+ });
529
+ const json = await res.json();
530
+ resultEl.className = 'result ' + (json.success ? 'ok' : 'err');
531
+ resultEl.style.display = 'block';
532
+ resultEl.textContent = json.success ? 'Profile updated.' : (json.error || 'Error');
533
+ } catch (err) {
534
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
535
+ resultEl.textContent = err.message;
536
+ }
537
+ btn.disabled = false; btn.textContent = 'Save Changes';
538
+ });
539
+
540
+ document.getElementById('leave-btn').addEventListener('click', async () => {
541
+ if (!confirm('Leave the salon? You can re-register at any time.')) return;
542
+ const btn = document.getElementById('leave-btn');
543
+ const resultEl = document.getElementById('leave-result');
544
+ btn.disabled = true; btn.textContent = 'Leaving…';
545
+ try {
546
+ const res = await fetch('/plugin/salon/member/leave', {
547
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
548
+ body: JSON.stringify({ emailHash: '${esc(tenant.emailHash)}' })
549
+ });
550
+ const json = await res.json();
551
+ if (json.success) {
552
+ window.location.href = '/plugin/salon';
553
+ } else {
554
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
555
+ resultEl.textContent = json.error || 'Error';
556
+ btn.disabled = false; btn.textContent = 'Leave the Salon';
557
+ }
558
+ } catch (err) {
559
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
560
+ resultEl.textContent = err.message;
561
+ btn.disabled = false; btn.textContent = 'Leave the Salon';
562
+ }
563
+ });
564
+ </script>
565
+ </body></html>`;
566
+ }
567
+
568
+ function generateAdminPage(config, tenants) {
569
+ const all = Object.values(tenants);
570
+ const pending = all.filter(t => t.status === 'pending');
571
+ const active = all.filter(t => t.status === 'active');
572
+ const denied = all.filter(t => t.status === 'denied');
573
+
574
+ const pendingHtml = pending.length === 0
575
+ ? '<div class="empty" style="padding:20px 0;">No pending applications.</div>'
576
+ : pending.map(t => `
577
+ <div class="card" style="display:flex;align-items:flex-start;gap:12px;">
578
+ <div style="flex:1;min-width:0;">
579
+ <div class="card-name">${esc(t.name)}</div>
580
+ ${t.description ? `<div class="card-desc">${esc(t.description)}</div>` : ''}
581
+ ${t.email ? `<div class="card-meta">${esc(t.email)}</div>` : ''}
582
+ <div class="card-meta">Applied ${timeAgo(t.registeredAt)}</div>
583
+ </div>
584
+ <div style="display:flex;gap:8px;flex-shrink:0;">
585
+ <button class="btn btn-sm btn-primary" onclick="grantTenant('${esc(t.uuid)}', this)">Grant</button>
586
+ <button class="btn btn-sm btn-danger" onclick="denyTenant('${esc(t.uuid)}', this)">Deny</button>
587
+ </div>
588
+ </div>`).join('');
589
+
590
+ const activeHtml = active.length === 0
591
+ ? '<div class="empty" style="padding:20px 0;">No active members.</div>'
592
+ : active.map(t => `
593
+ <div class="card" style="display:flex;align-items:center;gap:12px;">
594
+ <div style="flex:1;min-width:0;">
595
+ <div class="card-name">${esc(t.name)}</div>
596
+ ${t.description ? `<div class="card-desc">${esc(t.description)}</div>` : ''}
597
+ ${t.email ? `<div class="card-meta">${esc(t.email)}</div>` : ''}
598
+ <div class="card-meta">Since ${timeAgo(t.registeredAt)}</div>
599
+ </div>
600
+ <button class="btn btn-sm btn-danger" onclick="removeTenant('${esc(t.uuid)}', this)">Remove</button>
601
+ </div>`).join('');
602
+
603
+ return `<!DOCTYPE html><html lang="en"><head>${STYLE}<title>Salon Admin</title></head><body>
604
+ <div class="topbar">
605
+ <span class="topbar-title">✦ Salon Admin</span>
606
+ <div class="topbar-links"><a href="/plugin/salon">View salon</a></div>
607
+ </div>
608
+ <div class="container">
609
+ <h1>Salon Admin</h1>
610
+
611
+ <div class="section">
612
+ <div class="section-heading">Settings</div>
613
+ <div class="card">
614
+ <form id="config-form">
615
+ <label>Registration mode</label>
616
+ <select name="registrationMode">
617
+ <option value="open" ${config.registrationMode === 'open' ? 'selected' : ''}>Open — anyone can join</option>
618
+ <option value="grant" ${config.registrationMode === 'grant' ? 'selected' : ''}>By Application — owner approves each member</option>
619
+ <option value="closed" ${config.registrationMode === 'closed' ? 'selected' : ''}>Closed — no new registrations</option>
620
+ </select>
621
+
622
+ <label>Salon title</label>
623
+ <input name="title" value="${esc(config.title)}" placeholder="The Salon">
624
+
625
+ <label>Description</label>
626
+ <textarea name="description" rows="2">${esc(config.description)}</textarea>
627
+
628
+ <hr class="divider">
629
+
630
+ <div class="section-heading" style="margin-top:8px;">Email (Minnie + Joan)</div>
631
+
632
+ <label>From address</label>
633
+ <input name="fromEmail" value="${esc(config.fromEmail || 'salon@planetnine.app')}" placeholder="salon@planetnine.app">
634
+
635
+ <label>Minnie host</label>
636
+ <input name="minnieHost" value="${esc(String(config.minnieHost || 'localhost'))}" placeholder="localhost">
637
+
638
+ <label>Minnie port</label>
639
+ <input name="minniePort" type="number" value="${esc(String(config.minniePort || 2525))}" placeholder="2525">
640
+
641
+ <label>Joan URL</label>
642
+ <input name="joanUrl" value="${esc(config.joanUrl || 'http://localhost:3008')}" placeholder="http://localhost:3008">
643
+ <div class="form-hint">Used to send OTP codes for member account recovery.</div>
644
+
645
+ <div style="margin-top:16px;">
646
+ <button type="submit" class="btn btn-primary btn-sm">Save Settings</button>
647
+ </div>
648
+ <div class="result" id="config-result"></div>
649
+ </form>
650
+ </div>
651
+ </div>
652
+
653
+ <div class="section">
654
+ <div class="section-heading">Announce to All Members (${active.filter(t => t.email).length} with email)</div>
655
+ <div class="card">
656
+ <form id="announce-form">
657
+ <label>Subject</label>
658
+ <input name="subject" type="text" placeholder="Subject line" required maxlength="200">
659
+ <label>Message</label>
660
+ <textarea name="message" rows="5" placeholder="Your message to all members…" required maxlength="5000"></textarea>
661
+ <div style="margin-top:16px;">
662
+ <button type="submit" class="btn btn-secondary btn-sm">Send Announcement</button>
663
+ </div>
664
+ <div class="result" id="announce-result"></div>
665
+ </form>
666
+ </div>
667
+ </div>
668
+
669
+ ${config.registrationMode === 'grant' || pending.length > 0 ? `
670
+ <div class="section">
671
+ <div class="section-heading">Pending Applications (${pending.length})</div>
672
+ ${pendingHtml}
673
+ </div>` : ''}
674
+
675
+ <div class="section">
676
+ <div class="section-heading">Active Members (${active.length})</div>
677
+ ${activeHtml}
678
+ </div>
679
+
680
+ ${denied.length > 0 ? `
681
+ <div class="section">
682
+ <div class="section-heading">Denied (${denied.length})</div>
683
+ ${denied.map(t => `
684
+ <div class="card" style="display:flex;align-items:center;gap:12px;opacity:0.6;">
685
+ <div style="flex:1;"><div class="card-name">${esc(t.name)}</div></div>
686
+ <button class="btn btn-sm btn-primary" onclick="grantTenant('${esc(t.uuid)}', this)">Grant</button>
687
+ </div>`).join('')}
688
+ </div>` : ''}
689
+ </div>
690
+
691
+ <script>
692
+ document.getElementById('config-form').addEventListener('submit', async e => {
693
+ e.preventDefault();
694
+ const btn = e.target.querySelector('button[type=submit]');
695
+ const resultEl = document.getElementById('config-result');
696
+ btn.disabled = true;
697
+ const data = Object.fromEntries(new FormData(e.target));
698
+ try {
699
+ const res = await fetch('/plugin/salon/config', {
700
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
701
+ body: JSON.stringify(data)
702
+ });
703
+ const json = await res.json();
704
+ resultEl.className = 'result ' + (json.success ? 'ok' : 'err');
705
+ resultEl.style.display = 'block';
706
+ resultEl.textContent = json.success ? 'Settings saved.' : (json.error || 'Error');
707
+ if (json.success) setTimeout(() => location.reload(), 800);
708
+ } catch (err) {
709
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
710
+ resultEl.textContent = err.message;
711
+ }
712
+ btn.disabled = false;
713
+ });
714
+
715
+ document.getElementById('announce-form').addEventListener('submit', async e => {
716
+ e.preventDefault();
717
+ const btn = e.target.querySelector('button[type=submit]');
718
+ const resultEl = document.getElementById('announce-result');
719
+ btn.disabled = true; btn.textContent = 'Sending…';
720
+ const data = Object.fromEntries(new FormData(e.target));
721
+ try {
722
+ const res = await fetch('/plugin/salon/admin/announce', {
723
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify(data)
725
+ });
726
+ const json = await res.json();
727
+ resultEl.className = 'result ' + (json.success ? 'ok' : 'err');
728
+ resultEl.style.display = 'block';
729
+ resultEl.textContent = json.success
730
+ ? \`Sent to \${json.sent} member\${json.sent === 1 ? '' : 's'}.\`
731
+ : (json.error || 'Error');
732
+ } catch (err) {
733
+ resultEl.className = 'result err'; resultEl.style.display = 'block';
734
+ resultEl.textContent = err.message;
735
+ }
736
+ btn.disabled = false; btn.textContent = 'Send Announcement';
737
+ });
738
+
739
+ async function grantTenant(uuid, btn) {
740
+ btn.disabled = true; btn.textContent = '…';
741
+ const res = await fetch('/plugin/salon/admin/grant/' + uuid, { method: 'POST' });
742
+ const json = await res.json();
743
+ if (json.success) location.reload(); else { btn.disabled = false; btn.textContent = 'Grant'; alert(json.error); }
744
+ }
745
+
746
+ async function denyTenant(uuid, btn) {
747
+ btn.disabled = true; btn.textContent = '…';
748
+ const res = await fetch('/plugin/salon/admin/deny/' + uuid, { method: 'POST' });
749
+ const json = await res.json();
750
+ if (json.success) location.reload(); else { btn.disabled = false; btn.textContent = 'Deny'; alert(json.error); }
751
+ }
752
+
753
+ async function removeTenant(uuid, btn) {
754
+ if (!confirm('Remove this member?')) return;
755
+ btn.disabled = true; btn.textContent = '…';
756
+ const res = await fetch('/plugin/salon/admin/tenant/' + uuid, { method: 'DELETE' });
757
+ const json = await res.json();
758
+ if (json.success) location.reload(); else { btn.disabled = false; btn.textContent = 'Remove'; alert(json.error); }
759
+ }
760
+ </script>
761
+ </body></html>`;
762
+ }
763
+
764
+ // ── Freyja federation page ────────────────────────────────────────────────────
765
+
766
+ function generateFederationPage(currentId) {
767
+ const PLUGINS = {
768
+ agora: { id: 'agora', color: '#00cc00', icon: '🛍️', name: 'Agora', tagline: 'Digital marketplace', desc: 'A federated marketplace for independent creators. Buy and sell books, music, posts, and more — commerce the way it was supposed to work.', path: '/plugin/agora/directory', ping: '/plugin/agora/directory', fed: '/plugin/agora/federation' },
769
+ lucille: { id: 'lucille', color: '#ee22ee', icon: '🎬', name: 'Lucille', tagline: 'P2P video hosting', desc: 'Upload and stream video peer-to-peer. No corporate infrastructure, no surveillance — your wiki hosts your content directly.', path: '/plugin/lucille/setup', ping: '/plugin/lucille/setup/status', fed: '/plugin/lucille/federation' },
770
+ linkitylink: { id: 'linkitylink', color: '#9922cc', icon: '🔗', name: 'Linkitylink', tagline: 'Privacy-first link pages', desc: 'Create beautiful tapestries of links. No tracking, no algorithms — just your links, shared your way, on your terms.', path: '/plugin/linkitylink', ping: '/plugin/linkitylink/config', fed: '/plugin/linkitylink/federation' },
771
+ salon: { id: 'salon', color: '#ffdd00', icon: '🏛️', name: 'Salon', tagline: 'Community gathering space', desc: 'A gathering place for your wiki community. Members register, connect, and receive updates from the wider Freyja ecosystem.', path: '/plugin/salon', ping: '/plugin/salon/config', fed: '/plugin/salon/federation' },
772
+ };
773
+
774
+ const current = PLUGINS[currentId];
775
+ const others = Object.values(PLUGINS).filter(p => p.id !== currentId);
776
+
777
+ const navDotsHtml = Object.values(PLUGINS).map(p => `
778
+ <a href="${p.fed}" class="fnav-dot" style="--dot-color:${p.color};" title="${p.name}">
779
+ <span class="fnav-dot-inner"></span>
780
+ </a>`).join('');
781
+
782
+ const cardsHtml = others.map(p => `
783
+ <a href="${p.fed}" class="fed-card" style="--card-color:${p.color};">
784
+ <div class="fed-card-top">
785
+ <span class="fed-card-icon">${p.icon}</span>
786
+ <div class="fed-card-meta">
787
+ <div class="fed-card-name">
788
+ <span class="fed-status-dot" id="dot-${p.id}"></span>
789
+ ${p.name}
790
+ </div>
791
+ <div class="fed-card-tagline">${p.tagline}</div>
792
+ </div>
793
+ </div>
794
+ <div class="fed-card-desc">${p.desc}</div>
795
+ <div class="fed-card-cta">Explore ${p.name} →</div>
796
+ </a>`).join('');
797
+
798
+ return `<!DOCTYPE html>
799
+ <html lang="en">
800
+ <head>
801
+ <meta charset="UTF-8">
802
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
803
+ <title>Freyja — ${current.name}</title>
804
+ <link rel="preconnect" href="https://fonts.googleapis.com">
805
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
806
+ <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
807
+ <style>
808
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
809
+
810
+ :root {
811
+ --bg: #04040f;
812
+ --surface: rgba(12, 12, 30, 0.75);
813
+ --border: rgba(100, 120, 200, 0.18);
814
+ --text: #ffffff;
815
+ --text-muted: rgba(220, 225, 255, 0.88);
816
+ --text-dim: rgba(200, 210, 255, 0.65);
817
+ --radius-card: 1.25rem;
818
+ --radius-pill: 9999px;
819
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
820
+ --current-color: ${current.color};
821
+ }
822
+
823
+ html, body { height: 100%; }
824
+
825
+ body {
826
+ font-family: 'Inter', system-ui, sans-serif;
827
+ font-weight: 300;
828
+ background: var(--bg);
829
+ color: var(--text-muted);
830
+ min-height: 100vh;
831
+ overflow-x: hidden;
832
+ }
833
+
834
+ #starfield {
835
+ position: fixed;
836
+ inset: 0;
837
+ z-index: 0;
838
+ pointer-events: none;
839
+ }
840
+
841
+ .fnav {
842
+ position: fixed;
843
+ top: 0; left: 0; right: 0;
844
+ z-index: 100;
845
+ display: flex;
846
+ align-items: center;
847
+ justify-content: space-between;
848
+ padding: 0 2rem;
849
+ height: 56px;
850
+ background: rgba(4, 4, 15, 0.8);
851
+ backdrop-filter: blur(16px);
852
+ border-bottom: 1px solid var(--border);
853
+ }
854
+ .fnav-brand {
855
+ font-family: 'Orbitron', sans-serif;
856
+ font-weight: 700;
857
+ font-size: 1rem;
858
+ color: var(--current-color);
859
+ text-decoration: none;
860
+ filter: drop-shadow(0 0 8px var(--current-color));
861
+ letter-spacing: 0.06em;
862
+ }
863
+ .fnav-dots { display: flex; align-items: center; gap: 0.75rem; }
864
+ .fnav-dot {
865
+ display: flex;
866
+ align-items: center;
867
+ justify-content: center;
868
+ width: 28px;
869
+ height: 28px;
870
+ border-radius: 50%;
871
+ text-decoration: none;
872
+ transition: transform 0.2s var(--ease);
873
+ }
874
+ .fnav-dot:hover { transform: scale(1.25); }
875
+ .fnav-dot-inner {
876
+ width: 12px;
877
+ height: 12px;
878
+ border-radius: 50%;
879
+ background: var(--dot-color);
880
+ filter: drop-shadow(0 0 6px var(--dot-color));
881
+ }
882
+
883
+ .page { position: relative; z-index: 1; }
884
+
885
+ .hero {
886
+ min-height: 100vh;
887
+ display: flex;
888
+ flex-direction: column;
889
+ align-items: center;
890
+ justify-content: center;
891
+ text-align: center;
892
+ padding: 7rem 2rem 4rem;
893
+ gap: 1.25rem;
894
+ opacity: 0;
895
+ transform: translateY(24px);
896
+ transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
897
+ }
898
+ .hero.visible { opacity: 1; transform: none; }
899
+
900
+ .hero-eyebrow {
901
+ font-family: 'Orbitron', sans-serif;
902
+ font-size: 0.7rem;
903
+ font-weight: 600;
904
+ letter-spacing: 0.2em;
905
+ text-transform: uppercase;
906
+ color: var(--current-color);
907
+ }
908
+ .hero-icon {
909
+ font-size: 5rem;
910
+ line-height: 1;
911
+ filter: drop-shadow(0 0 24px ${current.color});
912
+ }
913
+ .hero-title {
914
+ font-family: 'Orbitron', sans-serif;
915
+ font-size: clamp(3rem, 10vw, 6rem);
916
+ font-weight: 900;
917
+ color: var(--current-color);
918
+ filter: drop-shadow(0 0 30px var(--current-color));
919
+ line-height: 1;
920
+ letter-spacing: -0.02em;
921
+ }
922
+ .hero-tagline {
923
+ font-family: 'Orbitron', sans-serif;
924
+ font-size: 0.75rem;
925
+ font-weight: 400;
926
+ letter-spacing: 0.18em;
927
+ text-transform: uppercase;
928
+ color: var(--text-dim);
929
+ }
930
+ .hero-desc {
931
+ max-width: 520px;
932
+ font-size: 1rem;
933
+ font-weight: 300;
934
+ color: var(--text-muted);
935
+ line-height: 1.7;
936
+ }
937
+ .hero-btn {
938
+ display: inline-block;
939
+ margin-top: 0.5rem;
940
+ padding: 0.75rem 2rem;
941
+ border-radius: var(--radius-pill);
942
+ background: var(--current-color);
943
+ color: #000;
944
+ font-family: 'Inter', sans-serif;
945
+ font-weight: 600;
946
+ font-size: 0.9rem;
947
+ text-decoration: none;
948
+ transition: transform 0.2s var(--ease), filter 0.2s;
949
+ }
950
+ .hero-btn:hover { transform: scale(1.04); filter: brightness(1.15); }
951
+
952
+ .cards-section {
953
+ max-width: 1000px;
954
+ margin: 0 auto;
955
+ padding: 4rem 2rem 6rem;
956
+ opacity: 0;
957
+ transform: translateY(32px);
958
+ transition: opacity 0.8s var(--ease) 0.15s, transform 0.8s var(--ease) 0.15s;
959
+ }
960
+ .cards-section.visible { opacity: 1; transform: none; }
961
+
962
+ .section-label {
963
+ font-family: 'Orbitron', sans-serif;
964
+ font-size: 0.65rem;
965
+ font-weight: 600;
966
+ letter-spacing: 0.22em;
967
+ text-transform: uppercase;
968
+ color: var(--text-dim);
969
+ margin-bottom: 1.5rem;
970
+ }
971
+
972
+ .fed-grid {
973
+ display: grid;
974
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
975
+ gap: 1.25rem;
976
+ }
977
+
978
+ .fed-card {
979
+ --card-color: #ffffff;
980
+ background: var(--surface);
981
+ backdrop-filter: blur(10px);
982
+ border: 1px solid var(--border);
983
+ border-radius: var(--radius-card);
984
+ padding: 1.5rem;
985
+ display: flex;
986
+ flex-direction: column;
987
+ gap: 1rem;
988
+ text-decoration: none;
989
+ color: var(--text-muted);
990
+ transition: border-color 0.25s var(--ease), filter 0.25s var(--ease), transform 0.25s var(--ease);
991
+ }
992
+ .fed-card:hover {
993
+ border-color: var(--card-color);
994
+ filter: drop-shadow(0 0 14px var(--card-color));
995
+ transform: translateY(-4px);
996
+ }
997
+ .fed-card-top { display: flex; align-items: flex-start; gap: 1rem; }
998
+ .fed-card-icon { font-size: 2rem; line-height: 1; flex-shrink: 0; }
999
+ .fed-card-meta { flex: 1; }
1000
+ .fed-card-name {
1001
+ font-family: 'Orbitron', sans-serif;
1002
+ font-size: 0.9rem;
1003
+ font-weight: 600;
1004
+ color: var(--text);
1005
+ display: flex;
1006
+ align-items: center;
1007
+ gap: 0.5rem;
1008
+ margin-bottom: 0.25rem;
1009
+ }
1010
+ .fed-status-dot {
1011
+ width: 8px;
1012
+ height: 8px;
1013
+ border-radius: 50%;
1014
+ background: rgba(200, 210, 255, 0.3);
1015
+ flex-shrink: 0;
1016
+ transition: background 0.4s, filter 0.4s;
1017
+ }
1018
+ .fed-card-tagline { font-size: 0.75rem; color: var(--card-color); font-weight: 500; }
1019
+ .fed-card-desc { font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); flex: 1; }
1020
+ .fed-card-cta { font-size: 0.8rem; font-weight: 600; color: var(--card-color); align-self: flex-start; }
1021
+
1022
+ .fed-footer {
1023
+ text-align: center;
1024
+ padding: 2rem;
1025
+ font-size: 0.78rem;
1026
+ color: var(--text-dim);
1027
+ border-top: 1px solid var(--border);
1028
+ position: relative;
1029
+ z-index: 1;
1030
+ }
1031
+ .fed-footer strong { font-family: 'Orbitron', sans-serif; color: var(--text-muted); }
1032
+ </style>
1033
+ </head>
1034
+ <body>
1035
+ <canvas id="starfield"></canvas>
1036
+
1037
+ <nav class="fnav">
1038
+ <a href="${current.fed}" class="fnav-brand">✦ FREYJA</a>
1039
+ <div class="fnav-dots">${navDotsHtml}
1040
+ </div>
1041
+ </nav>
1042
+
1043
+ <div class="page">
1044
+ <section class="hero" id="hero">
1045
+ <div class="hero-eyebrow">Freyja Ecosystem</div>
1046
+ <div class="hero-icon">${current.icon}</div>
1047
+ <div class="hero-title">${current.name}</div>
1048
+ <div class="hero-tagline">${current.tagline}</div>
1049
+ <div class="hero-desc">${current.desc}</div>
1050
+ <a href="${current.path}" class="hero-btn">Open ${current.name} →</a>
1051
+ </section>
1052
+
1053
+ <section class="cards-section" id="cards">
1054
+ <div class="section-label">Also on this wiki</div>
1055
+ <div class="fed-grid">${cardsHtml}
1056
+ </div>
1057
+ </section>
1058
+
1059
+ <footer class="fed-footer">
1060
+ <strong>Freyja</strong> — open, federated, and owned by you.
1061
+ </footer>
1062
+ </div>
1063
+
1064
+ <script>
1065
+ (function() {
1066
+ var canvas = document.getElementById('starfield');
1067
+ var ctx = canvas.getContext('2d');
1068
+ var stars = [];
1069
+ function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
1070
+ resize();
1071
+ window.addEventListener('resize', resize);
1072
+ for (var i = 0; i < 180; i++) {
1073
+ stars.push({ x: Math.random(), y: Math.random(), r: Math.random() * 1.2 + 0.2, a: Math.random(), da: (Math.random() - 0.5) * 0.008 });
1074
+ }
1075
+ function drawStars() {
1076
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1077
+ for (var i = 0; i < stars.length; i++) {
1078
+ var s = stars[i];
1079
+ s.a += s.da;
1080
+ if (s.a <= 0 || s.a >= 1) s.da = -s.da;
1081
+ ctx.beginPath();
1082
+ ctx.arc(s.x * canvas.width, s.y * canvas.height, s.r, 0, Math.PI * 2);
1083
+ ctx.fillStyle = 'rgba(200,210,255,' + s.a.toFixed(2) + ')';
1084
+ ctx.fill();
1085
+ }
1086
+ requestAnimationFrame(drawStars);
1087
+ }
1088
+ drawStars();
1089
+
1090
+ var obs = new IntersectionObserver(function(entries) {
1091
+ entries.forEach(function(e) { if (e.isIntersecting) e.target.classList.add('visible'); });
1092
+ }, { threshold: 0.1 });
1093
+ obs.observe(document.getElementById('hero'));
1094
+ obs.observe(document.getElementById('cards'));
1095
+
1096
+ var pluginColors = { agora: '#00cc00', lucille: '#ee22ee', linkitylink: '#9922cc', salon: '#ffdd00' };
1097
+ var pings = [
1098
+ { id: 'agora', url: '/plugin/agora/directory' },
1099
+ { id: 'lucille', url: '/plugin/lucille/setup/status' },
1100
+ { id: 'linkitylink', url: '/plugin/linkitylink/config' },
1101
+ { id: 'salon', url: '/plugin/salon/config' }
1102
+ ];
1103
+ pings.forEach(function(p) {
1104
+ fetch(p.url, { signal: AbortSignal.timeout(3000) })
1105
+ .then(function(r) {
1106
+ var d = document.getElementById('dot-' + p.id);
1107
+ if (d) {
1108
+ d.style.background = r.ok ? pluginColors[p.id] : 'rgba(200,210,255,0.3)';
1109
+ if (r.ok) d.style.filter = 'drop-shadow(0 0 5px ' + pluginColors[p.id] + ')';
1110
+ }
1111
+ })
1112
+ .catch(function() {});
1113
+ });
1114
+ })();
1115
+ </script>
1116
+ </body>
1117
+ </html>`;
1118
+ }
1119
+
1120
+ // ── startServer ───────────────────────────────────────────────────────────────
1121
+
1122
+ function startServer(params) {
1123
+ const app = params.app;
1124
+ ensureDir();
1125
+
1126
+ // ── Owner auth ──────────────────────────────────────────────────────────────
1127
+
1128
+ const owner = (req, res, next) => {
1129
+ if (app.securityhandler && app.securityhandler.isAuthorized(req)) return next();
1130
+ return res.status(403).json({ error: 'Owner only' });
1131
+ };
1132
+
1133
+ // JSON body parser for salon routes
1134
+ const json = require('express').json({ limit: '1mb' });
1135
+
1136
+ // ── Public: salon page ──────────────────────────────────────────────────────
1137
+
1138
+ app.get('/plugin/salon', (req, res) => {
1139
+ const config = loadConfig();
1140
+ const tenants = loadTenants();
1141
+ const events = loadEvents();
1142
+ const active = Object.values(tenants).filter(t => t.status === 'active')
1143
+ .sort((a, b) => b.registeredAt - a.registeredAt);
1144
+ res.send(generateSalonPage(config, active, events));
1145
+ });
1146
+
1147
+ // ── Public: config (for wiki item) ─────────────────────────────────────────
1148
+
1149
+ app.get('/plugin/salon/config', (req, res) => {
1150
+ const config = loadConfig();
1151
+ const { registrationMode, title, description } = config;
1152
+ const tenants = loadTenants();
1153
+ const pendingCount = Object.values(tenants).filter(t => t.status === 'pending').length;
1154
+ const activeCount = Object.values(tenants).filter(t => t.status === 'active').length;
1155
+ const isOwnerReq = !!(app.securityhandler && app.securityhandler.isAuthorized(req));
1156
+ const allyabaseUrl = config.allyabaseUrl ||
1157
+ (config.joanUrl ? config.joanUrl.replace(/\/plugin\/allyabase\/joan$/, '') : '');
1158
+ res.json({
1159
+ registrationMode, title, description, pendingCount, activeCount,
1160
+ isOwner: isOwnerReq,
1161
+ ...(isOwnerReq && { allyabaseUrl })
1162
+ });
1163
+ });
1164
+
1165
+ // ── Owner: update config ────────────────────────────────────────────────────
1166
+
1167
+ app.post('/plugin/salon/config', owner, json, (req, res) => {
1168
+ const config = loadConfig();
1169
+ const { registrationMode, title, description, fromEmail, minnieHost, minniePort, joanUrl, allyabaseUrl } = req.body;
1170
+ const modes = ['open', 'closed', 'grant'];
1171
+ if (registrationMode && modes.includes(registrationMode)) config.registrationMode = registrationMode;
1172
+ if (title && title.trim()) config.title = title.trim();
1173
+ if (description !== undefined) config.description = description.trim();
1174
+ if (fromEmail !== undefined) config.fromEmail = fromEmail.trim();
1175
+ if (minnieHost !== undefined) config.minnieHost = minnieHost.trim();
1176
+ if (minniePort !== undefined) config.minniePort = parseInt(minniePort) || 2525;
1177
+ if (joanUrl !== undefined) config.joanUrl = joanUrl.trim();
1178
+ if (allyabaseUrl !== undefined) {
1179
+ config.allyabaseUrl = allyabaseUrl.trim();
1180
+ config.joanUrl = allyabaseUrl.trim().replace(/\/$/, '') + '/plugin/allyabase/joan';
1181
+ }
1182
+ saveConfig(config);
1183
+ const derivedAllyabaseUrl = config.allyabaseUrl ||
1184
+ (config.joanUrl ? config.joanUrl.replace(/\/plugin\/allyabase\/joan$/, '') : '');
1185
+ res.json({ success: true, allyabaseUrl: derivedAllyabaseUrl });
1186
+ });
1187
+
1188
+ // ── Public: registration form ───────────────────────────────────────────────
1189
+
1190
+ app.get('/plugin/salon/register', (req, res) => {
1191
+ const config = loadConfig();
1192
+ if (config.registrationMode === 'closed') return res.send(generateClosedPage(config));
1193
+ res.send(generateRegisterPage(config));
1194
+ });
1195
+
1196
+ app.post('/plugin/salon/register', json, (req, res) => {
1197
+ const config = loadConfig();
1198
+ if (config.registrationMode === 'closed') {
1199
+ return res.status(403).json({ error: 'Registration is closed' });
1200
+ }
1201
+ const { name = '', email = '', description = '', pubKey = '' } = req.body;
1202
+ if (!name || name.trim().length < 2) {
1203
+ return res.status(400).json({ error: 'Name is required (at least 2 characters)' });
1204
+ }
1205
+ if (name.trim().length > 80) {
1206
+ return res.status(400).json({ error: 'Name too long (max 80 characters)' });
1207
+ }
1208
+ if (!email || !email.includes('@')) {
1209
+ return res.status(400).json({ error: 'A valid email address is required' });
1210
+ }
1211
+
1212
+ const tenants = loadTenants();
1213
+
1214
+ // Prevent duplicate email registrations
1215
+ const eHash = hashEmail(email);
1216
+ const existing = findTenantByEmailHash(tenants, eHash);
1217
+ if (existing && existing.status !== 'denied') {
1218
+ return res.status(409).json({ error: 'This email is already registered. Visit /plugin/salon/recover to manage your membership.' });
1219
+ }
1220
+
1221
+ const uuid = generateId();
1222
+ const status = config.registrationMode === 'grant' ? 'pending' : 'active';
1223
+
1224
+ tenants[uuid] = {
1225
+ uuid,
1226
+ name: name.trim(),
1227
+ email: email.toLowerCase().trim(),
1228
+ emailHash: eHash,
1229
+ description: description.trim().slice(0, 500),
1230
+ pubKey: pubKey.trim().slice(0, 130),
1231
+ registeredAt: Date.now(),
1232
+ status
1233
+ };
1234
+ saveTenants(tenants);
1235
+
1236
+ if (status === 'active') {
1237
+ appendEvent({ source: 'salon', event: 'member_joined', data: { name: name.trim(), uuid } });
1238
+ }
1239
+
1240
+ res.json({
1241
+ success: true,
1242
+ uuid,
1243
+ status,
1244
+ message: status === 'pending'
1245
+ ? 'Application submitted — the wiki owner will review your request.'
1246
+ : 'Welcome to the salon!'
1247
+ });
1248
+ });
1249
+
1250
+ // ── Public: tenant profile ──────────────────────────────────────────────────
1251
+
1252
+ app.get('/plugin/salon/tenant/:uuid', (req, res) => {
1253
+ const tenants = loadTenants();
1254
+ const tenant = tenants[req.params.uuid];
1255
+ if (!tenant || tenant.status !== 'active') return res.status(404).send(generateClosedPage(loadConfig()));
1256
+ res.send(generateTenantPage(tenant));
1257
+ });
1258
+
1259
+ // ── Public: event feed ──────────────────────────────────────────────────────
1260
+
1261
+ app.get('/plugin/salon/feed', (req, res) => {
1262
+ const events = loadEvents();
1263
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
1264
+ const source = req.query.source;
1265
+ const filtered = source ? events.filter(e => e.source === source) : events;
1266
+ res.json(filtered.slice(0, limit));
1267
+ });
1268
+
1269
+ app.get('/plugin/salon/feed.json', (req, res) => {
1270
+ res.json(loadEvents().slice(0, 50));
1271
+ });
1272
+
1273
+ // ── Public: notify (agora, lucille, etc. POST events here) ─────────────────
1274
+
1275
+ app.post('/plugin/salon/notify', json, (req, res) => {
1276
+ const { source, event, tenantId, data = {} } = req.body;
1277
+ if (!source || typeof source !== 'string' || source.length > 50) {
1278
+ return res.status(400).json({ error: 'source required (string, max 50 chars)' });
1279
+ }
1280
+ if (!event || typeof event !== 'string' || event.length > 100) {
1281
+ return res.status(400).json({ error: 'event required (string, max 100 chars)' });
1282
+ }
1283
+ appendEvent({
1284
+ source: source.slice(0, 50),
1285
+ event: event.slice(0, 100),
1286
+ tenantId: tenantId || null,
1287
+ data: typeof data === 'object' ? data : {}
1288
+ });
1289
+ res.json({ success: true });
1290
+ });
1291
+
1292
+ // ── Account recovery: send OTP via Joan ────────────────────────────────────
1293
+
1294
+ app.get('/plugin/salon/recover', (req, res) => {
1295
+ res.send(generateRecoverPage(loadConfig()));
1296
+ });
1297
+
1298
+ app.post('/plugin/salon/recover/send', json, async (req, res) => {
1299
+ const { email } = req.body;
1300
+ if (!email || !email.includes('@')) {
1301
+ return res.status(400).json({ error: 'Valid email required' });
1302
+ }
1303
+
1304
+ // Make sure this email is actually registered
1305
+ const tenants = loadTenants();
1306
+ const eHash = hashEmail(email);
1307
+ const tenant = findTenantByEmailHash(tenants, eHash);
1308
+ if (!tenant) {
1309
+ // Don't reveal whether the email exists — just say "if registered, you'll get a code"
1310
+ return res.json({ success: true });
1311
+ }
1312
+
1313
+ const config = loadConfig();
1314
+ try {
1315
+ const result = await joanPost(config.joanUrl || 'http://localhost:3008', '/auth/email/send-otp', { email });
1316
+ if (result.body && result.body.success) {
1317
+ res.json({ success: true });
1318
+ } else {
1319
+ res.status(502).json({ error: 'Could not send code. Is Joan running?' });
1320
+ }
1321
+ } catch (err) {
1322
+ console.error('salon: Joan OTP send error:', err.message);
1323
+ res.status(502).json({ error: 'Could not reach Joan service' });
1324
+ }
1325
+ });
1326
+
1327
+ app.post('/plugin/salon/recover/verify', json, async (req, res) => {
1328
+ const { email, code } = req.body;
1329
+ if (!email || !code) {
1330
+ return res.status(400).json({ error: 'Email and code required' });
1331
+ }
1332
+
1333
+ const config = loadConfig();
1334
+ try {
1335
+ const result = await joanPost(config.joanUrl || 'http://localhost:3008', '/auth/email/verify-otp', { email, code });
1336
+ if (result.body && result.body.valid) {
1337
+ const eHash = result.body.emailHash || hashEmail(email);
1338
+ res.json({ success: true, emailHash: eHash });
1339
+ } else {
1340
+ res.status(401).json({ error: result.body && result.body.error || 'Invalid or expired code' });
1341
+ }
1342
+ } catch (err) {
1343
+ console.error('salon: Joan OTP verify error:', err.message);
1344
+ res.status(502).json({ error: 'Could not reach Joan service' });
1345
+ }
1346
+ });
1347
+
1348
+ // ── Member self-service (authenticated by emailHash from Joan OTP) ──────────
1349
+
1350
+ app.get('/plugin/salon/member/:emailHash', (req, res) => {
1351
+ const tenants = loadTenants();
1352
+ const tenant = findTenantByEmailHash(tenants, req.params.emailHash);
1353
+ if (!tenant) return res.status(404).json({ error: 'Member not found' });
1354
+ res.send(generateMemberPage(loadConfig(), tenant));
1355
+ });
1356
+
1357
+ app.post('/plugin/salon/member/update', json, (req, res) => {
1358
+ const { emailHash, name, description } = req.body;
1359
+ if (!emailHash) return res.status(400).json({ error: 'emailHash required' });
1360
+
1361
+ const tenants = loadTenants();
1362
+ const tenant = findTenantByEmailHash(tenants, emailHash);
1363
+ if (!tenant) return res.status(404).json({ error: 'Member not found' });
1364
+
1365
+ if (name && name.trim().length >= 2) tenant.name = name.trim().slice(0, 80);
1366
+ if (description !== undefined) tenant.description = description.trim().slice(0, 500);
1367
+ saveTenants(tenants);
1368
+ res.json({ success: true });
1369
+ });
1370
+
1371
+ app.post('/plugin/salon/member/leave', json, (req, res) => {
1372
+ const { emailHash } = req.body;
1373
+ if (!emailHash) return res.status(400).json({ error: 'emailHash required' });
1374
+
1375
+ const tenants = loadTenants();
1376
+ const tenant = findTenantByEmailHash(tenants, emailHash);
1377
+ if (!tenant) return res.status(404).json({ error: 'Member not found' });
1378
+
1379
+ delete tenants[tenant.uuid];
1380
+ saveTenants(tenants);
1381
+ appendEvent({ source: 'salon', event: 'member_left', data: { name: tenant.name } });
1382
+ res.json({ success: true });
1383
+ });
1384
+
1385
+ // ── Owner: admin page ───────────────────────────────────────────────────────
1386
+
1387
+ app.get('/plugin/salon/admin', owner, (req, res) => {
1388
+ res.send(generateAdminPage(loadConfig(), loadTenants()));
1389
+ });
1390
+
1391
+ app.post('/plugin/salon/admin/grant/:uuid', owner, (req, res) => {
1392
+ const tenants = loadTenants();
1393
+ const t = tenants[req.params.uuid];
1394
+ if (!t) return res.status(404).json({ error: 'Tenant not found' });
1395
+ t.status = 'active';
1396
+ saveTenants(tenants);
1397
+ appendEvent({ source: 'salon', event: 'member_joined', data: { name: t.name, uuid: t.uuid } });
1398
+ res.json({ success: true });
1399
+ });
1400
+
1401
+ app.post('/plugin/salon/admin/deny/:uuid', owner, (req, res) => {
1402
+ const tenants = loadTenants();
1403
+ if (!tenants[req.params.uuid]) return res.status(404).json({ error: 'Tenant not found' });
1404
+ tenants[req.params.uuid].status = 'denied';
1405
+ saveTenants(tenants);
1406
+ res.json({ success: true });
1407
+ });
1408
+
1409
+ app.delete('/plugin/salon/admin/tenant/:uuid', owner, (req, res) => {
1410
+ const tenants = loadTenants();
1411
+ if (!tenants[req.params.uuid]) return res.status(404).json({ error: 'Tenant not found' });
1412
+ delete tenants[req.params.uuid];
1413
+ saveTenants(tenants);
1414
+ res.json({ success: true });
1415
+ });
1416
+
1417
+ // ── Owner: blast-email all active members ───────────────────────────────────
1418
+
1419
+ app.post('/plugin/salon/admin/announce', owner, json, async (req, res) => {
1420
+ const { subject, message } = req.body;
1421
+ if (!subject || !message) {
1422
+ return res.status(400).json({ error: 'subject and message required' });
1423
+ }
1424
+
1425
+ const config = loadConfig();
1426
+ const tenants = loadTenants();
1427
+ const recipients = Object.values(tenants).filter(t => t.status === 'active' && t.email);
1428
+
1429
+ if (recipients.length === 0) {
1430
+ return res.json({ success: true, sent: 0 });
1431
+ }
1432
+
1433
+ const transporter = createTransporter(config);
1434
+ const from = `"${config.title}" <${config.fromEmail || 'salon@planetnine.app'}>`;
1435
+ let sent = 0;
1436
+
1437
+ for (const tenant of recipients) {
1438
+ try {
1439
+ await transporter.sendMail({
1440
+ from,
1441
+ to: tenant.email,
1442
+ subject: subject.slice(0, 200),
1443
+ text: `${message}\n\n—\nManage your membership: /plugin/salon/recover`
1444
+ });
1445
+ sent++;
1446
+ } catch (err) {
1447
+ console.error(`salon: announce email failed for ${tenant.email}:`, err.message);
1448
+ }
1449
+ }
1450
+
1451
+ res.json({ success: true, sent });
1452
+ });
1453
+
1454
+ // ── Freyja federation page ──────────────────────────────────────────────────
1455
+
1456
+ app.get('/plugin/salon/federation', (req, res) => {
1457
+ res.send(generateFederationPage('salon'));
1458
+ });
1459
+
1460
+ console.log('✅ wiki-plugin-salon ready');
1461
+ console.log(' /plugin/salon → public salon page');
1462
+ console.log(' /plugin/salon/register → self-registration');
1463
+ console.log(' /plugin/salon/recover → account recovery (Joan OTP)');
1464
+ console.log(' /plugin/salon/admin → owner admin (requires owner auth)');
1465
+ console.log(' /plugin/salon/notify → event intake from agora, lucille, etc.');
1466
+ }
1467
+
1468
+ module.exports = { startServer };
1469
+ }).call(this);