OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social commits / 53bb967

Full settings page, explicit login, anonymous read-only, admin delete
Settings page (matching pear-git/hypernote patterns): - Identity card with avatar, @username, permission badge - Username setting/changing, display name editing - Theme selection (light/dark/dark-blue) with CSS variables - Linked identities list with method icons + copy buttons - Keypair management (copy pubkey, copy full, show/hide, export JSON) - Device linking (import keypair) + account merge initiation - Connected platform accounts (Mastodon/Bluesky) - Danger zone (clear identity/logout) Auth model: - Anonymous visitors = read-only, see only local posts, no keypair generated, no user created, no compose/notifications nav - Explicit Login button generates keypair + registers (first = admin) - Import Keypair for device linking across browsers - Settings nav shows "Login" for anonymous users Admin panel: - User list with promote + delete buttons - DELETE /api/admin/users/:userId cascade deletes user + identities Server endpoints added: - GET /api/user/profile (full profile with identities) - POST /api/auth/register (explicit registration) - POST /api/auth/import-keypair (device linking) - POST /api/merge/initiate + confirm + GET status - GET /api/users (public directory) - DELETE /api/admin/users/:userId - Strip @ from handles on account connect - Forward platform API errors to browser Vendored noble-crypto.js (required by noble-sha512.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
53bb967006e08a0a678ac555185fa8c467a5550b
by plankatron <fleeky@gmail.com> · 2026-03-25T01:48:43.000Z

Changed files (7) +940 -20

hyper-social-web/lib/auth.js +1 -0
……@@ -29,6 +29,7 @@ export async function createAuth (store) {
2929 identityRegistry: registry,
3030 db: identityBee,
3131 enableSSH: false,
32 autoRegister: false, // Require explicit registration — no auto-create on auth
3233 checkPermission: checkSocialPermission,
3334 rateLimits: {
3435 auth: { tokens: 100, refillRate: 20, refillInterval: 60 * 1000 },
hyper-social-web/lib/server.js +203 -5
……@@ -168,7 +168,7 @@ async function createServer (opts = {}) {
168168
169169 // CORS for local dev
170170 res.setHeader('Access-Control-Allow-Origin', '*')
171 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
171 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
172172 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Identity-PublicKey, X-Identity-Signature, X-Identity-Challenge')
173173 if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
174174
……@@ -221,12 +221,108 @@ async function handleApi (url, req, res, ctx) {
221221
222222 const user = auth.user || await identityRegistry.getUser(auth.userId)
223223 json(res, {
224 user: user ? { userId: user.userId, username: user.username, name: user.name } : null,
224 user: user ? {
225 userId: user.userId,
226 username: user.username,
227 name: user.name,
228 globalPermissions: user.globalPermissions || [],
229 admin: !!(user.globalPermissions && user.globalPermissions.includes('admin'))
230 } : null,
225231 publicKey: auth.identityId
226232 })
227233 return
228234 }
229235
236 // GET /api/admin/users — list all users (admin only)
237 if (url === '/api/admin/users' && method === 'GET') {
238 const auth = await requireAuth(req, authManager)
239 const user = auth.user || await identityRegistry.getUser(auth.userId)
240 if (!user || !user.globalPermissions || !user.globalPermissions.includes('admin')) {
241 error(res, 'Admin access required', 403); return
242 }
243
244 const users = await identityRegistry.listUsers()
245 json(res, {
246 users: users.map(u => ({
247 userId: u.userId,
248 username: u.username,
249 name: u.name,
250 globalPermissions: u.globalPermissions || [],
251 identities: u.identities || [],
252 createdAt: u.createdAt
253 }))
254 })
255 return
256 }
257
258 // POST /api/admin/promote — promote user to admin (admin only)
259 if (url === '/api/admin/promote' && method === 'POST') {
260 const auth = await requireAuth(req, authManager)
261 const adminUser = auth.user || await identityRegistry.getUser(auth.userId)
262 if (!adminUser || !adminUser.globalPermissions || !adminUser.globalPermissions.includes('admin')) {
263 error(res, 'Admin access required', 403); return
264 }
265
266 const body = await bodyJson(req)
267 if (!body.userId) { error(res, 'userId required'); return }
268
269 await identityRegistry.upgradePermissions(body.userId, '*', ['read', 'write', 'admin'])
270 const updated = await identityRegistry.getUser(body.userId)
271 json(res, { user: { userId: updated.userId, username: updated.username, globalPermissions: updated.globalPermissions } })
272 return
273 }
274
275 // POST /api/auth/register — explicit registration (creates user + identity)
276 if (url === '/api/auth/register' && method === 'POST') {
277 const body = await bodyJson(req)
278 if (!body.publicKey || body.publicKey.length !== 44) {
279 error(res, 'publicKey required (44-char base64)'); return
280 }
281
282 // Check if already registered
283 const existing = await identityRegistry.getIdentityByPublicKey(body.publicKey)
284 if (existing) {
285 // Already registered — just return the user
286 const user = await identityRegistry.getUserByIdentity(existing.identityId)
287 json(res, { user: user ? { userId: user.userId, username: user.username, admin: !!(user.globalPermissions && user.globalPermissions.includes('admin')) } : null, existing: true })
288 return
289 }
290
291 // Register new user
292 const result = await identityRegistry.autoRegister({
293 identityId: body.publicKey,
294 method: 'web-identity',
295 credentials: { publicKey: body.publicKey },
296 name: body.username || 'Anonymous'
297 })
298
299 json(res, {
300 user: {
301 userId: result.user.userId,
302 username: result.user.username,
303 admin: !!(result.user.globalPermissions && result.user.globalPermissions.includes('admin'))
304 },
305 existing: false
306 })
307 return
308 }
309
310 // DELETE /api/admin/users/:userId — delete user (admin only)
311 if (url.startsWith('/api/admin/users/') && method === 'DELETE') {
312 const auth = await requireAuth(req, authManager)
313 const adminUser = auth.user || await identityRegistry.getUser(auth.userId)
314 if (!adminUser || !adminUser.globalPermissions || !adminUser.globalPermissions.includes('admin')) {
315 error(res, 'Admin access required', 403); return
316 }
317
318 const userId = decodeURIComponent(url.slice('/api/admin/users/'.length))
319 if (userId === auth.userId) { error(res, 'Cannot delete yourself', 400); return }
320
321 await identityRegistry.deleteUserCascade(userId)
322 json(res, { ok: true })
323 return
324 }
325
230326 // POST /api/auth/username — set username (requires auth)
231327 if (url === '/api/auth/username' && method === 'POST') {
232328 const auth = await requireAuth(req, authManager)
……@@ -238,6 +334,98 @@ async function handleApi (url, req, res, ctx) {
238334 return
239335 }
240336
337 // GET /api/user/profile — full profile with identities (requires auth)
338 if (url === '/api/user/profile' && method === 'GET') {
339 const auth = await requireAuth(req, authManager)
340 const full = await identityRegistry.getUserWithIdentities(auth.userId)
341 if (!full) { json(res, { user: null }); return }
342
343 json(res, {
344 user: {
345 userId: full.user.userId,
346 username: full.user.username,
347 name: full.user.name,
348 theme: full.user.theme || 'dark',
349 globalPermissions: full.user.globalPermissions || [],
350 admin: !!(full.user.globalPermissions && full.user.globalPermissions.includes('admin')),
351 createdAt: full.user.createdAt
352 },
353 identities: full.identities.map(id => ({
354 identityId: id.identityId,
355 method: id.method,
356 publicKey: id.publicKey,
357 createdAt: id.createdAt
358 })),
359 publicKey: auth.identityId
360 })
361 return
362 }
363
364 // PATCH /api/auth/profile — update name/theme (requires auth)
365 if (url === '/api/auth/profile' && method === 'PATCH') {
366 const auth = await requireAuth(req, authManager)
367 const body = await bodyJson(req)
368 const updated = await identityRegistry.updateUserProfile(auth.userId, body)
369 json(res, {
370 user: {
371 userId: updated.userId,
372 username: updated.username,
373 name: updated.name,
374 theme: updated.theme || 'dark'
375 }
376 })
377 return
378 }
379
380 // POST /api/auth/import-keypair — link a new keypair to current user (requires auth)
381 if (url === '/api/auth/import-keypair' && method === 'POST') {
382 const auth = await requireAuth(req, authManager)
383 const body = await bodyJson(req)
384 if (!body.publicKey) { error(res, 'publicKey required'); return }
385
386 const identity = await identityRegistry.importIdentity(auth.userId, body.publicKey, body.method || 'web-identity')
387 json(res, { identity: { identityId: identity.identityId, method: identity.method, publicKey: identity.publicKey } })
388 return
389 }
390
391 // POST /api/merge/initiate — start account merge (requires auth)
392 if (url === '/api/merge/initiate' && method === 'POST') {
393 const auth = await requireAuth(req, authManager)
394 const body = await bodyJson(req)
395 if (!body.targetPublicKey) { error(res, 'targetPublicKey required'); return }
396
397 const merge = await authManager.initiateMerge(auth.userId, body.targetPublicKey)
398 json(res, merge)
399 return
400 }
401
402 // POST /api/merge/:mergeId/confirm — confirm merge with target signature
403 if (url.match(/^\/api\/merge\/[^/]+\/confirm$/) && method === 'POST') {
404 const mergeId = url.split('/')[3]
405 const body = await bodyJson(req)
406 if (!body.signature) { error(res, 'signature required'); return }
407
408 const result = await authManager.confirmMerge(mergeId, body.signature)
409 json(res, result)
410 return
411 }
412
413 // GET /api/merge/:mergeId — get merge status
414 if (url.match(/^\/api\/merge\/[^/]+$/) && method === 'GET' && !url.includes('/confirm')) {
415 const mergeId = url.split('/')[3]
416 const merge = await authManager.getMerge(mergeId)
417 if (!merge) { error(res, 'Merge not found', 404); return }
418 json(res, merge)
419 return
420 }
421
422 // GET /api/users — public user directory (no auth)
423 if (url === '/api/users' && method === 'GET') {
424 const users = await identityRegistry.listPublicUsers()
425 json(res, { users })
426 return
427 }
428
241429 // --- Write endpoints (require auth) ---
242430
243431 // POST /api/post — POSSE publish (text only, JSON body)
……@@ -316,13 +504,23 @@ async function handleApi (url, req, res, ctx) {
316504
317505 if (!platform) { error(res, 'platform required'); return }
318506
319 const id = `${platform}:${handle || instance || 'default'}`
320 const account = { id, platform, instance, handle, token, password, connectedAt: Date.now() }
507 // Strip @ prefix from handles (Bluesky rejects it)
508 const cleanHandle = handle ? handle.replace(/^@/, '') : handle
509
510 const id = `${platform}:${cleanHandle || instance || 'default'}`
511 const account = { id, platform, instance, handle: cleanHandle, token, password, connectedAt: Date.now() }
321512
322513 await accountsBee.put(`account/${id}`, account)
323514
324515 // Register client with POSSE engine
325 await connectAccount(account, posse)
516 try {
517 await connectAccount(account, posse)
518 } catch (err) {
519 // Remove failed account from storage
520 await accountsBee.del(`account/${id}`)
521 error(res, err.message || 'Failed to connect account')
522 return
523 }
326524
327525 const safe = { ...account }
328526 delete safe.token
hyper-social-web/web/css/app.css +101 -0
……@@ -151,7 +151,108 @@ nav a.active { color: var(--accent); background: var(--bg-card); }
151151.compose-actions { display: flex; justify-content: space-between; align-items: center; }
152152#char-count { font-size: 13px; color: var(--text-muted); }
153153
154/* Themes */
155[data-theme="light"] {
156 --bg: #ffffff;
157 --bg-card: #f6f8fa;
158 --bg-input: #ffffff;
159 --border: #d0d7de;
160 --text: #1f2328;
161 --text-muted: #656d76;
162 --accent: #0969da;
163 --accent-hover: #0550ae;
164}
165
166[data-theme="dark-blue"] {
167 --bg: #0a1628;
168 --bg-card: #0f2040;
169 --bg-input: #0a1628;
170 --border: #1e3a5f;
171 --text: #c9d9ef;
172 --text-muted: #7a8da6;
173 --accent: #4a9eff;
174 --accent-hover: #6db3ff;
175}
176
154177/* Settings */
178.settings-card {
179 background: var(--bg-card);
180 border: 1px solid var(--border);
181 border-radius: var(--radius);
182 padding: 14px;
183 margin-bottom: 12px;
184}
185
186.settings-row {
187 display: flex;
188 align-items: center;
189 justify-content: space-between;
190 padding: 6px 0;
191}
192
193.settings-row + .settings-row { border-top: 1px solid var(--border); }
194
195.settings-label { font-size: 13px; color: var(--text-muted); min-width: 100px; }
196.settings-value { font-size: 14px; }
197
198.identity-card-header {
199 display: flex;
200 align-items: center;
201 gap: 12px;
202 margin-bottom: 10px;
203}
204
205.identity-avatar {
206 width: 48px;
207 height: 48px;
208 border-radius: 50%;
209 background: var(--border);
210 display: flex;
211 align-items: center;
212 justify-content: center;
213 font-size: 24px;
214 flex-shrink: 0;
215}
216
217.identity-name { font-size: 18px; font-weight: 600; }
218.identity-handle { font-size: 14px; color: var(--text-muted); }
219
220.perm-badge {
221 display: inline-block;
222 font-size: 11px;
223 padding: 2px 8px;
224 border-radius: 12px;
225 font-weight: 600;
226 text-transform: uppercase;
227}
228
229.perm-badge.admin { background: rgba(248,81,73,0.2); color: var(--red); }
230.perm-badge.write { background: rgba(88,166,255,0.2); color: var(--accent); }
231.perm-badge.read { background: rgba(139,148,158,0.2); color: var(--text-muted); }
232
233.theme-buttons { display: flex; gap: 8px; }
234.theme-btn.active { border-color: var(--accent); color: var(--accent); }
235
236.mono { font-family: monospace; font-size: 12px; word-break: break-all; }
237
238.identity-item {
239 display: flex;
240 align-items: center;
241 gap: 8px;
242 padding: 8px 0;
243}
244
245.identity-item + .identity-item { border-top: 1px solid var(--border); }
246
247.identity-method {
248 font-size: 12px;
249 padding: 2px 6px;
250 border-radius: 4px;
251 background: var(--bg-input);
252 border: 1px solid var(--border);
253 white-space: nowrap;
254}
255
155256.connect-form { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
156257.connect-form select, .connect-form input {
157258 display: block;
hyper-social-web/web/index.html +55 -1
……@@ -15,7 +15,9 @@
1515 <a href="#/compose" class="nav-link" data-page="compose">Compose</a>
1616 <a href="#/notifications" class="nav-link" data-page="notifications">Notifications</a>
1717 <a href="#/settings" class="nav-link" data-page="settings">Settings</a>
18 <a href="#/admin" class="nav-link" data-page="admin" id="nav-admin" style="display:none">Admin</a>
1819 </nav>
20 <div id="identity-status" style="font-size:0.8em;color:var(--text-muted);margin-left:auto"></div>
1921 </header>
2022
2123 <main>
……@@ -69,7 +71,45 @@
6971
7072 <!-- Settings -->
7173 <div id="page-settings" class="page" style="display:none">
72 <h2>Connected Accounts</h2>
74
75 <!-- Identity Card -->
76 <div id="identity-card" class="settings-card"></div>
77
78 <!-- Username & Display Name -->
79 <h3>Profile</h3>
80 <div id="profile-section" class="settings-card"></div>
81
82 <!-- Theme -->
83 <h3>Theme</h3>
84 <div class="settings-card">
85 <div class="theme-buttons">
86 <button class="btn btn-small theme-btn" data-theme="light">Light</button>
87 <button class="btn btn-small theme-btn" data-theme="dark">Dark</button>
88 <button class="btn btn-small theme-btn" data-theme="dark-blue">Dark Blue</button>
89 </div>
90 </div>
91
92 <!-- Linked Identities -->
93 <h3>Identities</h3>
94 <div id="identities-section" class="settings-card"></div>
95
96 <!-- Keypair Management -->
97 <h3>Keypair</h3>
98 <div id="keypair-section" class="settings-card"></div>
99
100 <!-- Device Linking & Merge -->
101 <h3>Device Linking</h3>
102 <div class="settings-card">
103 <p style="color:var(--text-muted);font-size:13px;margin-bottom:8px">Use the same identity across browsers/devices by importing your keypair, or merge a separate account.</p>
104 <div style="display:flex;gap:8px;flex-wrap:wrap">
105 <button class="btn btn-small" onclick="showImportDialog()">Import Keypair</button>
106 <button class="btn btn-small" onclick="showMergeDialog()">Merge Another Account</button>
107 </div>
108 <div id="device-link-result" class="result" style="display:none"></div>
109 </div>
110
111 <!-- Connected Accounts -->
112 <h3>Connected Accounts</h3>
73113 <div id="accounts-list"></div>
74114
75115 <h3>Add Account</h3>
……@@ -93,6 +133,20 @@
93133 <button id="connect-btn" class="btn btn-primary">Connect</button>
94134 <div id="connect-result" class="result" style="display:none"></div>
95135 </div>
136
137 <!-- Danger Zone -->
138 <h3 style="color:var(--red)">Danger Zone</h3>
139 <div class="settings-card" style="border-color:var(--red)">
140 <p style="color:var(--text-muted);font-size:13px;margin-bottom:8px">Clear your identity from this browser. You'll need to import your keypair to log back in.</p>
141 <button class="btn btn-small btn-danger" onclick="logout()">Clear Identity &amp; Logout</button>
142 </div>
143 </div>
144 <!-- Admin -->
145 <div id="page-admin" class="page" style="display:none">
146 <h2>Admin</h2>
147 <div id="admin-info" style="margin-bottom:16px"></div>
148 <h3>Users</h3>
149 <div id="admin-users"></div>
96150 </div>
97151 </main>
98152 </div>
hyper-social-web/web/js/app.js +523 -10
……@@ -4,6 +4,8 @@
44
55let currentSource = ''
66let oldestTimestamp = null
7let currentUser = null
8let currentProfile = null
79
810// --- Router ---
911function route () {
……@@ -14,17 +16,30 @@ function route () {
1416 pages.forEach(p => { p.style.display = 'none' })
1517 links.forEach(l => l.classList.remove('active'))
1618
17 if (hash.startsWith('#/compose')) {
19 const isLoggedIn = !!(currentUser && currentUser.userId)
20
21 // Hide nav items for anonymous users
22 document.querySelector('[data-page="compose"]').style.display = isLoggedIn ? '' : 'none'
23 document.querySelector('[data-page="notifications"]').style.display = isLoggedIn ? '' : 'none'
24 // Settings only visible when logged in — anonymous gets login page
25 const settingsLabel = document.querySelector('[data-page="settings"]')
26 if (settingsLabel) settingsLabel.textContent = isLoggedIn ? 'Settings' : 'Login'
27
28 if (hash.startsWith('#/compose') && isLoggedIn) {
1829 document.getElementById('page-compose').style.display = 'block'
1930 document.querySelector('[data-page="compose"]').classList.add('active')
20 } else if (hash.startsWith('#/notifications')) {
31 } else if (hash.startsWith('#/notifications') && isLoggedIn) {
2132 document.getElementById('page-notifications').style.display = 'block'
2233 document.querySelector('[data-page="notifications"]').classList.add('active')
2334 loadNotifications()
2435 } else if (hash.startsWith('#/settings')) {
2536 document.getElementById('page-settings').style.display = 'block'
2637 document.querySelector('[data-page="settings"]').classList.add('active')
27 loadAccounts()
38 loadSettings()
39 } else if (hash.startsWith('#/admin')) {
40 document.getElementById('page-admin').style.display = 'block'
41 document.querySelector('[data-page="admin"]').classList.add('active')
42 loadAdmin()
2843 } else {
2944 document.getElementById('page-timeline').style.display = 'block'
3045 document.querySelector('[data-page="timeline"]').classList.add('active')
……@@ -32,10 +47,405 @@ function route () {
3247 }
3348}
3449
50// --- Identity status in header ---
51async function loadIdentityStatus () {
52 if (typeof Identity === 'undefined' || !Identity.isReady) return
53
54 try {
55 const data = await API.me()
56 currentUser = data.user
57 const statusEl = document.getElementById('identity-status')
58
59 if (data.user) {
60 const name = data.user.username ? '@' + data.user.username : (data.user.name || 'Anonymous')
61 const badge = data.user.admin ? ' [admin]' : ''
62 statusEl.textContent = name + badge
63
64 if (data.user.admin) {
65 document.getElementById('nav-admin').style.display = ''
66 }
67 } else {
68 statusEl.textContent = ''
69 }
70 } catch (err) {
71 console.error('[identity] Failed to load status:', err.message)
72 }
73}
74
75// --- Settings page ---
76async function loadSettings () {
77 const isLoggedIn = !!(currentUser && currentUser.userId)
78
79 if (!isLoggedIn) {
80 // Show login page for anonymous
81 renderIdentityCard()
82 // Hide all other settings sections
83 document.getElementById('profile-section').innerHTML = ''
84 document.getElementById('identities-section').innerHTML = ''
85 document.getElementById('keypair-section').innerHTML = ''
86 document.getElementById('accounts-list').innerHTML = ''
87 return
88 }
89
90 // Fetch full profile with identities
91 try {
92 const res = await API._fetch('/api/user/profile')
93 const data = await res.json()
94 if (data.user) {
95 currentProfile = data
96 currentUser = data.user
97 }
98 } catch {}
99
100 renderIdentityCard()
101 renderProfileSection()
102 renderIdentitiesSection()
103 renderKeypairSection()
104 updateThemeButtons()
105 loadAccounts()
106}
107
108function renderIdentityCard () {
109 const el = document.getElementById('identity-card')
110 if (!currentUser) {
111 el.innerHTML = `
112 <div style="color:var(--text-muted);margin-bottom:12px">Not logged in.</div>
113 <div style="display:flex;gap:8px;flex-wrap:wrap">
114 <button class="btn btn-primary" onclick="doLogin()">Login</button>
115 <button class="btn btn-small" onclick="showImportDialog()">Import Keypair</button>
116 </div>
117 <p style="font-size:12px;color:var(--text-muted);margin-top:8px">
118 Login generates a new identity. Import Keypair links an existing identity from another device.
119 </p>
120 `
121 return
122 }
123
124 const u = currentUser
125 const permLevel = u.admin ? 'admin' : (u.globalPermissions && u.globalPermissions.includes('write') ? 'write' : 'read')
126 const permLabel = permLevel.charAt(0).toUpperCase() + permLevel.slice(1)
127
128 el.innerHTML = `
129 <div class="identity-card-header">
130 <div class="identity-avatar">&#128100;</div>
131 <div>
132 <div class="identity-name">${esc(u.name || 'Anonymous')}</div>
133 <div class="identity-handle">${u.username ? '@' + esc(u.username) : '<em>no username set</em>'}</div>
134 </div>
135 <span class="perm-badge ${permLevel}" style="margin-left:auto">${permLabel}</span>
136 </div>
137 <div style="font-size:12px;color:var(--text-muted)">
138 User ID: <code class="mono">${esc(u.userId)}</code>
139 ${u.createdAt ? ' &middot; Joined ' + new Date(u.createdAt).toLocaleDateString() : ''}
140 </div>
141 `
142}
143
144function renderProfileSection () {
145 const el = document.getElementById('profile-section')
146 if (!currentUser) { el.innerHTML = ''; return }
147
148 const u = currentUser
149 el.innerHTML = `
150 <div class="settings-row">
151 <span class="settings-label">Username</span>
152 <span class="settings-value">
153 ${u.username
154 ? '@' + esc(u.username) + ' <button class="btn btn-small" onclick="promptUsername()">Change</button>'
155 : '<button class="btn btn-small" onclick="promptUsername()">Set username</button>'
156 }
157 </span>
158 </div>
159 <div class="settings-row">
160 <span class="settings-label">Display Name</span>
161 <span class="settings-value">
162 ${esc(u.name || 'Anonymous')}
163 <button class="btn btn-small" onclick="promptDisplayName()">Edit</button>
164 </span>
165 </div>
166 `
167}
168
169function renderIdentitiesSection () {
170 const el = document.getElementById('identities-section')
171 if (!currentProfile || !currentProfile.identities) {
172 el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">No identities loaded</div>'
173 return
174 }
175
176 const methodIcons = {
177 'web-identity': '&#127760;',
178 'ssh-key': '&#128274;',
179 'keet-identity': '&#128179;',
180 'bearer-token': '&#128273;'
181 }
182
183 el.innerHTML = currentProfile.identities.map(id => `
184 <div class="identity-item">
185 <span>${methodIcons[id.method] || '&#128100;'}</span>
186 <span class="identity-method">${esc(id.method)}</span>
187 <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(id.publicKey || id.identityId)}</code>
188 <button class="btn btn-small" onclick="copyText('${esc(id.publicKey || id.identityId)}')">Copy</button>
189 </div>
190 `).join('')
191}
192
193function renderKeypairSection () {
194 const el = document.getElementById('keypair-section')
195 if (typeof Identity === 'undefined' || !Identity.isReady) {
196 el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">Identity not initialized</div>'
197 return
198 }
199
200 const pubkey = Identity.publicKey || ''
201 const keypair = localStorage.getItem('hs-keypair') || ''
202
203 el.innerHTML = `
204 <div class="settings-row">
205 <span class="settings-label">Public Key</span>
206 <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(pubkey)}</code>
207 <button class="btn btn-small" onclick="copyText('${esc(pubkey)}')">Copy</button>
208 </div>
209 <div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap">
210 <button class="btn btn-small" onclick="toggleKeypairDisplay()">Show Full Keypair</button>
211 <button class="btn btn-small" onclick="copyText('${esc(keypair)}')">Copy Full Keypair</button>
212 <button class="btn btn-small" onclick="exportKeypairJSON()">Export JSON</button>
213 </div>
214 <div id="keypair-display" style="display:none;margin-top:8px">
215 <div style="background:var(--bg-input);padding:8px;border-radius:var(--radius);border:1px solid var(--border)">
216 <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Full Keypair (132 chars) — <span style="color:var(--red);font-weight:600">KEEP SECRET</span></div>
217 <code class="mono" style="color:var(--red)">${esc(keypair)}</code>
218 </div>
219 </div>
220 `
221}
222
223function toggleKeypairDisplay () {
224 const el = document.getElementById('keypair-display')
225 el.style.display = el.style.display === 'none' ? 'block' : 'none'
226}
227
228function exportKeypairJSON () {
229 const keypair = localStorage.getItem('hs-keypair')
230 if (!keypair) { alert('No keypair found'); return }
231
232 const data = JSON.stringify({
233 publicKey: keypair.substring(0, 44),
234 keypair: keypair,
235 exportedAt: new Date().toISOString(),
236 app: 'hyper-social-web'
237 }, null, 2)
238
239 const blob = new Blob([data], { type: 'application/json' })
240 const url = URL.createObjectURL(blob)
241 const a = document.createElement('a')
242 a.href = url
243 a.download = 'hyper-social-keypair.json'
244 a.click()
245 URL.revokeObjectURL(url)
246}
247
248function copyText (text) {
249 navigator.clipboard.writeText(text).then(() => {
250 // Brief visual feedback would be nice but keep it simple
251 }).catch(() => {
252 // Fallback for older browsers
253 const ta = document.createElement('textarea')
254 ta.value = text
255 document.body.appendChild(ta)
256 ta.select()
257 document.execCommand('copy')
258 document.body.removeChild(ta)
259 })
260}
261
262// --- Login ---
263async function doLogin () {
264 if (typeof Identity === 'undefined') { alert('Identity system not loaded'); return }
265
266 // Generate a new keypair for this user
267 if (!Identity.publicKey) {
268 const keypair = await Identity._generateKeypair()
269 localStorage.setItem('hs-keypair', keypair)
270 // Re-init to load the new keypair
271 await Identity.init(API.base)
272 }
273
274 if (!Identity.publicKey) { alert('Failed to generate keypair'); return }
275
276 // Register with the server
277 const base = API.base
278 const result = await Identity.register(base)
279 if (!result || result.error) {
280 alert('Registration failed: ' + (result ? result.error : 'unknown error'))
281 return
282 }
283
284 Identity.markRegistered()
285 currentUser = result.user
286
287 // Prompt for username
288 const username = prompt('Choose a username (@handle):')
289 if (username) {
290 try {
291 const res = await API.setUsername(username.replace(/^@/, ''))
292 if (!res.error) {
293 Identity.setUsername(res.user.username)
294 currentUser = { ...currentUser, username: res.user.username }
295 }
296 } catch {}
297 }
298
299 await loadIdentityStatus()
300 loadSettings()
301}
302
303// --- Profile editing ---
304async function promptUsername () {
305 const username = prompt('Choose a username (lowercase, 3-32 chars, letters/numbers/dashes):')
306 if (!username) return
307
308 try {
309 const res = await API.setUsername(username)
310 if (res.error) { alert('Error: ' + res.error); return }
311 if (typeof Identity !== 'undefined') Identity.setUsername(username)
312 currentUser = { ...currentUser, username }
313 loadSettings()
314 loadIdentityStatus()
315 } catch (err) {
316 alert('Error: ' + err.message)
317 }
318}
319
320async function promptDisplayName () {
321 const name = prompt('Enter display name:', currentUser ? currentUser.name : '')
322 if (!name) return
323
324 try {
325 const res = await API._fetch('/api/auth/profile', {
326 method: 'PATCH',
327 headers: { 'Content-Type': 'application/json' },
328 body: JSON.stringify({ name })
329 })
330 const data = await res.json()
331 if (data.error) { alert('Error: ' + data.error); return }
332 currentUser = { ...currentUser, name }
333 loadSettings()
334 loadIdentityStatus()
335 } catch (err) {
336 alert('Error: ' + err.message)
337 }
338}
339
340// --- Theme ---
341function updateThemeButtons () {
342 const current = currentUser ? (currentUser.theme || 'dark') : (localStorage.getItem('hs-theme') || 'dark')
343 document.querySelectorAll('.theme-btn').forEach(btn => {
344 btn.classList.toggle('active', btn.dataset.theme === current)
345 })
346}
347
348async function setTheme (theme) {
349 document.documentElement.setAttribute('data-theme', theme)
350 localStorage.setItem('hs-theme', theme)
351
352 if (currentUser) {
353 try {
354 await API._fetch('/api/auth/profile', {
355 method: 'PATCH',
356 headers: { 'Content-Type': 'application/json' },
357 body: JSON.stringify({ theme })
358 })
359 currentUser.theme = theme
360 } catch {}
361 }
362 updateThemeButtons()
363}
364
365// --- Device linking ---
366function showImportDialog () {
367 const input = prompt('Paste your 132-character keypair string:')
368 if (!input) return
369
370 if (input.length !== 132) {
371 alert('Invalid keypair length. Expected 132 characters (44 public + 88 secret).')
372 return
373 }
374
375 const newPubkey = input.substring(0, 44)
376 const resultEl = document.getElementById('device-link-result')
377
378 // Register the new pubkey with the server (link to current user)
379 if (currentUser) {
380 API._fetch('/api/auth/import-keypair', {
381 method: 'POST',
382 headers: { 'Content-Type': 'application/json' },
383 body: JSON.stringify({ publicKey: newPubkey })
384 }).then(res => res.json()).then(data => {
385 if (data.error) {
386 showResult(resultEl, 'Import failed: ' + data.error, 'error')
387 return
388 }
389 // Save new keypair locally
390 localStorage.setItem('hs-keypair', input)
391 localStorage.setItem('hs-pubkey', newPubkey)
392 showResult(resultEl, 'Keypair imported. Reloading...', 'success')
393 setTimeout(() => location.reload(), 1000)
394 }).catch(err => {
395 showResult(resultEl, err.message, 'error')
396 })
397 } else {
398 // No user yet — just store locally
399 localStorage.setItem('hs-keypair', input)
400 showResult(resultEl, 'Keypair imported. Reloading...', 'success')
401 setTimeout(() => location.reload(), 1000)
402 }
403}
404
405function showMergeDialog () {
406 const targetKey = prompt('Paste the 44-character public key of the account to merge into yours:')
407 if (!targetKey) return
408
409 if (targetKey.length !== 44) {
410 alert('Invalid public key length. Expected 44 characters (base64).')
411 return
412 }
413
414 const resultEl = document.getElementById('device-link-result')
415
416 API._fetch('/api/merge/initiate', {
417 method: 'POST',
418 headers: { 'Content-Type': 'application/json' },
419 body: JSON.stringify({ targetPublicKey: targetKey })
420 }).then(res => res.json()).then(data => {
421 if (data.error) {
422 showResult(resultEl, 'Merge failed: ' + data.error, 'error')
423 return
424 }
425 showResult(resultEl, `Merge initiated (ID: ${data.mergeId.substring(0, 8)}...). The other account must confirm by signing challenge: ${data.challenge.substring(0, 16)}... Expires: ${new Date(data.expiresAt).toLocaleTimeString()}`, 'success')
426 }).catch(err => {
427 showResult(resultEl, err.message, 'error')
428 })
429}
430
431// --- Logout ---
432function logout () {
433 if (!confirm('Clear your identity from this browser? You will need your keypair to log back in.')) return
434 if (typeof Identity !== 'undefined') Identity.clear()
435 location.reload()
436}
437
35438// --- Timeline ---
36439async function loadTimeline (append) {
37440 const container = document.getElementById('timeline')
38441 const loadMoreBtn = document.getElementById('load-more')
442 const isLoggedIn = !!(currentUser && currentUser.userId)
443
444 // Hide filters for anonymous users
445 const filtersEl = document.querySelector('.filters')
446 const refreshBtn = document.getElementById('refresh-btn')
447 if (filtersEl) filtersEl.style.display = isLoggedIn ? 'flex' : 'none'
448 if (refreshBtn) refreshBtn.style.display = isLoggedIn ? '' : 'none'
39449
40450 if (!append) {
41451 container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>'
……@@ -43,22 +453,23 @@ async function loadTimeline (append) {
43453 }
44454
45455 const opts = { limit: 30 }
46 if (currentSource) opts.source = currentSource
456 // Anonymous users only see local (your) posts
457 if (!isLoggedIn) {
458 opts.source = 'local'
459 } else if (currentSource) {
460 opts.source = currentSource
461 }
47462 if (append && oldestTimestamp) opts.before = oldestTimestamp
48463
49464 let data
50465 try {
51 console.log('[timeline] fetching', API.base + '/api/timeline', opts)
52466 data = await API.timeline(opts)
53 console.log('[timeline] got', data.items ? data.items.length : 0, 'items')
54467 } catch (err) {
55 console.error('[timeline] fetch error:', err)
56 container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + err.message + '</div>'
468 container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + esc(err.message) + '</div>'
57469 return
58470 }
59471
60472 const items = data.items || []
61
62473 if (!append) container.innerHTML = ''
63474
64475 if (items.length === 0 && !append) {
……@@ -118,13 +529,110 @@ async function disconnectAccount (id) {
118529 loadAccounts()
119530}
120531
532// --- Admin ---
533async function loadAdmin () {
534 const infoEl = document.getElementById('admin-info')
535 const usersEl = document.getElementById('admin-users')
536
537 if (!currentUser || !currentUser.admin) {
538 infoEl.innerHTML = '<div style="color:var(--red)">Admin access required</div>'
539 usersEl.innerHTML = ''
540 return
541 }
542
543 infoEl.innerHTML = '<div>Logged in as admin: <strong>' + esc(currentUser.username || currentUser.name) + '</strong></div>'
544 usersEl.innerHTML = '<div style="color:var(--text-muted)">Loading users...</div>'
545
546 try {
547 const data = await API._fetch('/api/admin/users')
548 const res = await data.json()
549 const users = res.users || []
550
551 if (users.length === 0) {
552 usersEl.innerHTML = '<div style="color:var(--text-muted)">No users</div>'
553 return
554 }
555
556 usersEl.innerHTML = users.map(u => {
557 const perms = (u.globalPermissions || []).join(', ')
558 const isAdmin = u.globalPermissions && u.globalPermissions.includes('admin')
559 const promoteBtn = isAdmin ? '' : ` <button class="btn btn-small" onclick="promoteUser('${esc(u.userId)}')">Promote</button>`
560 const isSelf = currentUser && u.userId === currentUser.userId
561 const deleteBtn = isSelf ? '' : ` <button class="btn btn-small btn-danger" onclick="deleteUser('${esc(u.userId)}')">Delete</button>`
562 return `
563 <div class="settings-card">
564 <strong>${esc(u.username ? '@' + u.username : u.name || 'Anonymous')}</strong>
565 <span style="color:var(--text-muted);font-size:0.85em;margin-left:8px">${esc(u.userId.substring(0, 8))}...</span>
566 <span class="perm-badge ${isAdmin ? 'admin' : 'read'}" style="margin-left:8px">${esc(perms)}</span>
567 ${promoteBtn}${deleteBtn}
568 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
569 Identities: ${u.identities.length} | Created: ${new Date(u.createdAt).toLocaleDateString()}
570 </div>
571 </div>
572 `
573 }).join('')
574 } catch (err) {
575 usersEl.innerHTML = '<div style="color:var(--red)">Error: ' + esc(err.message) + '</div>'
576 }
577}
578
579async function deleteUser (userId) {
580 if (!confirm('Delete this user and all their identities? This cannot be undone.')) return
581 try {
582 const res = await API._fetch('/api/admin/users/' + encodeURIComponent(userId), { method: 'DELETE' })
583 const data = await res.json()
584 if (data.error) { alert('Error: ' + data.error); return }
585 loadAdmin()
586 } catch (err) {
587 alert('Error: ' + err.message)
588 }
589}
590
591async function promoteUser (userId) {
592 if (!confirm('Promote this user to admin?')) return
593 try {
594 const res = await API._fetch('/api/admin/promote', {
595 method: 'POST',
596 headers: { 'Content-Type': 'application/json' },
597 body: JSON.stringify({ userId })
598 })
599 const data = await res.json()
600 if (data.error) { alert('Error: ' + data.error); return }
601 loadAdmin()
602 } catch (err) {
603 alert('Error: ' + err.message)
604 }
605}
606
607// --- Helpers ---
608function esc (str) {
609 if (!str) return ''
610 const div = document.createElement('div')
611 div.textContent = String(str)
612 return div.innerHTML
613}
614
615function showResult (el, msg, type) {
616 if (!el) return
617 el.textContent = msg
618 el.className = 'result ' + type
619 el.style.display = 'block'
620 setTimeout(() => { el.style.display = 'none' }, 8000)
621}
622
121623// --- Init ---
122624document.addEventListener('DOMContentLoaded', async () => {
123 // Initialize identity (generates keypair on first visit)
625 // Apply saved theme
626 const savedTheme = localStorage.getItem('hs-theme') || 'dark'
627 document.documentElement.setAttribute('data-theme', savedTheme)
628
629 // Initialize identity
124630 if (typeof Identity !== 'undefined') {
125631 await Identity.init(API.base)
126632 }
127633
634 await loadIdentityStatus()
635
128636 initComposer()
129637
130638 // Filter buttons
……@@ -155,6 +663,11 @@ document.addEventListener('DOMContentLoaded', async () => {
155663 // Load more
156664 document.getElementById('load-more').addEventListener('click', () => loadTimeline(true))
157665
666 // Theme buttons
667 document.querySelectorAll('.theme-btn').forEach(btn => {
668 btn.addEventListener('click', () => setTheme(btn.dataset.theme))
669 })
670
158671 // Platform fields toggle
159672 document.getElementById('connect-platform').addEventListener('change', (e) => {
160673 document.getElementById('mastodon-fields').style.display = e.target.value === 'mastodon' ? 'block' : 'none'
hyper-social-web/web/js/identity.js +49 -4
……@@ -32,13 +32,14 @@ const Identity = {
3232 return
3333 }
3434
35 // Load or generate keypair
35 // Load existing keypair — do NOT auto-generate for anonymous visitors
3636 const stored = localStorage.getItem('hs-keypair')
3737 if (stored && stored.length === 132) {
3838 this._keypair = stored
3939 } else {
40 this._keypair = await this._generateKeypair()
41 localStorage.setItem('hs-keypair', this._keypair)
40 // No keypair — user is anonymous until they explicitly login
41 this._ready = true
42 return
4243 }
4344
4445 this._publicKey = this._keypair.substring(0, 44)
……@@ -101,7 +102,7 @@ const Identity = {
101102 const headers = { ...(opts.headers || {}) }
102103 headers['X-Identity-PublicKey'] = this._publicKey
103104
104 // Get challenge and sign it
105 // Get a fresh challenge and sign it (challenges are single-use)
105106 const base = API ? API.base : ''
106107 const challenge = await this.getChallenge(base)
107108 if (challenge) {
……@@ -110,11 +111,43 @@ const Identity = {
110111 headers['X-Identity-Signature'] = sig
111112 headers['X-Identity-Challenge'] = challenge.challengeId
112113 }
114 // Clear cached challenge — it's consumed on use
115 this._challenge = null
116 this._challengeExpiry = 0
113117 }
114118
115119 return fetch(url, { ...opts, headers })
116120 },
117121
122 /**
123 * Register this keypair with the server (explicit login)
124 */
125 async register (base) {
126 if (!this._publicKey) return null
127
128 try {
129 const res = await fetch((base || '') + '/api/auth/register', {
130 method: 'POST',
131 headers: { 'Content-Type': 'application/json' },
132 body: JSON.stringify({ publicKey: this._publicKey })
133 })
134 return res.json()
135 } catch {
136 return null
137 }
138 },
139
140 /**
141 * Check if this keypair is registered (without creating)
142 */
143 get isRegistered () {
144 return this._ready && this._publicKey && localStorage.getItem('hs-registered') === 'true'
145 },
146
147 markRegistered () {
148 localStorage.setItem('hs-registered', 'true')
149 },
150
118151 get publicKey () { return this._publicKey },
119152 get username () { return this._username },
120153 get isReady () { return this._ready },
……@@ -122,6 +155,18 @@ const Identity = {
122155 setUsername (username) {
123156 this._username = username
124157 localStorage.setItem('hs-username', username)
158 },
159
160 clear () {
161 localStorage.removeItem('hs-keypair')
162 localStorage.removeItem('hs-pubkey')
163 localStorage.removeItem('hs-username')
164 localStorage.removeItem('hs-registered')
165 this._keypair = null
166 this._publicKey = null
167 this._secretKey = null
168 this._username = null
169 this._ready = false
125170 }
126171}
127172
hyper-social-web/web/vendor/noble-crypto.js +8 -0
……@@ -0,0 +1,8 @@
1/**
2 * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
3 * Original file: /npm/@noble/hashes@1.3.0/esm/crypto.js
4 *
5 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 */
7const o="object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0;export{o as crypto};export default null;
8//# sourceMappingURL=/sm/c89c3eead1f3ff4004cc3626795fb3273c573384fdf3d96f44a89e5143a967f7.map