OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / cd1ce3a

Phase 1: Core booking server with auth
Working booking system with: - Server: bare-http1, Corestore + Hyperbee, same pattern as hypernote - Auth: copied from hypernote (ANProto ed25519 challenge-response) - Providers: CRUD, public profile pages at /:slug - Packages: CRUD, 5 types (single, bundle, subscription, discovery, group) - Availability: weekly schedule, date overrides, buffer time - Slots: calculation algorithm (availability - existing bookings) - Bookings: create, cancel, reschedule, complete, notes - UI: Paperbell-inspired design, calendar slot picker, responsive - Seed script: sample provider with 4 packages + Mon-Fri availability - Vendored libs: noble-ed25519, noble-sha512, marked.js (zero CDN) Run: npm install && bare scripts/seed.js && bare index.js serve Visit: http://localhost:4050/alexis-mattos Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cd1ce3ad1ed917d0206be7c2ff5c7124df953837
by plankatron <fleeky@gmail.com> · 2026-03-22T17:50:06.000Z

Changed files (28) +4790 -0

index.js +67 -0
……@@ -0,0 +1,67 @@
1#!/usr/bin/env bare
2
3/**
4 * hyper-booking — P2P booking platform
5 *
6 * Phase 1: Core booking + auth
7 */
8
9await import('bare-node-runtime/global')
10
11import fs from 'bare-fs'
12import path from 'bare-path'
13import { HyperBookingServer } from './lib/server.js'
14
15// Version
16let version = '0.1.0'
17{
18 const appDir = path.dirname(new URL(import.meta.url).pathname)
19 try {
20 const pkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf-8'))
21 version = pkg.version || version
22 } catch {}
23}
24
25process.on('uncaughtException', (err) => {
26 console.error('[FATAL] Uncaught exception:', err.message, err.stack)
27})
28process.on('unhandledRejection', (reason) => {
29 console.error('[WARN] Unhandled rejection:', reason && reason.message ? reason.message : reason)
30})
31
32// Parse args
33const rawArgs = process.argv.slice(2)
34const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs
35const command = args[0] || 'serve'
36
37if (command === 'serve') {
38 let port = 4050
39 let storage = null
40 let network = false
41 let prefix = ''
42 let host = 'localhost'
43
44 for (const arg of args.slice(1)) {
45 if (arg.startsWith('--port=')) port = parseInt(arg.split('=')[1])
46 else if (arg.startsWith('--storage=')) storage = arg.split('=')[1]
47 else if (arg === '--network') { network = true; host = '0.0.0.0' }
48 else if (arg.startsWith('--prefix=')) prefix = arg.split('=')[1]
49 else if (/^\d+$/.test(arg)) port = parseInt(arg)
50 }
51
52 const server = new HyperBookingServer({
53 port,
54 host,
55 storage,
56 pathPrefix: prefix,
57 network
58 })
59
60 await server.ready()
61 await server.start()
62
63 console.log(`[hyper-booking] v${version} listening on http://${host}:${port}${prefix}`)
64} else {
65 console.log(`Unknown command: ${command}`)
66 console.log('Usage: bare index.js serve [port] [--storage=path] [--network] [--prefix=/path]')
67}
lib/auth.js +375 -0
……@@ -0,0 +1,375 @@
1import { IdentityRegistry } from './auth/identity-registry.js'
2import { WebIdentityProvider } from './auth/providers/web-identity.js'
3import { BearerTokenProvider } from './auth/providers/bearer-token.js'
4import { KeetIdentityProvider } from './auth/providers/keet-identity.js'
5import { ChallengeManager } from './auth/challenge-manager.js'
6import { RateLimiter } from './auth/rate-limiter.js'
7
8/**
9 * Authentication and Authorization System
10 *
11 * Multi-method authentication with identity linking:
12 * - Web Identity: Browser-based ANProto keypairs (auto-generated)
13 * - Bearer Tokens: API tokens with embedded permissions
14 * - Keet Identity: BIP39 mnemonic-based identities
15 *
16 * Key Concepts:
17 * - User: A person with permissions (can have multiple identities)
18 * - Identity: An authentication method
19 * - Linking: Multiple identities can link to the same user
20 */
21export class AuthManager {
22 constructor(options = {}) {
23 this.hyperbee = options.registry
24 this.trustedProxies = options.trustedProxies || ['127.0.0.1', '::1', '::ffff:127.0.0.1']
25
26 // Initialize identity registry
27 this.identityRegistry = new IdentityRegistry(this.hyperbee)
28
29 // Initialize challenge manager (with persistent storage)
30 this.challengeManager = new ChallengeManager({
31 db: this.hyperbee,
32 ttl: options.challengeTTL || 5 * 60 * 1000, // 5 minutes
33 cacheTimeout: options.challengeCacheTimeout || 30 * 1000 // 30 seconds
34 })
35
36 // Initialize rate limiter (with persistent storage)
37 this.rateLimiter = new RateLimiter({
38 db: this.hyperbee,
39 limits: options.rateLimits || {
40 'auth': { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
41 'api': { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
42 'notes': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 },
43 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
44 },
45 cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 // 10 seconds
46 })
47
48 // Register auth providers (no SSH for notes)
49 this.providers = [
50 new WebIdentityProvider(this.identityRegistry, this.challengeManager),
51 new KeetIdentityProvider(this.identityRegistry),
52 new BearerTokenProvider(this.identityRegistry)
53 ]
54
55 // Get bearer token provider for backward compat
56 this.bearerProvider = this.providers.find(p => p.name === 'bearer-token')
57 }
58
59 /**
60 * Authenticate request using all available providers
61 * @param {Object} req - HTTP request
62 * @param {Object} options - { skipRateLimit, operation }
63 * @returns {Promise<Object|null>} Auth result or null
64 */
65 async authenticate(req, options = {}) {
66 // Apply rate limiting (unless explicitly skipped)
67 if (!options.skipRateLimit) {
68 const clientIp = this.getClientIp(req)
69 const operation = options.operation || 'auth'
70
71 const rateLimit = await this.rateLimiter.check(clientIp, operation)
72
73 if (!rateLimit.allowed) {
74 const error = new Error('Rate limit exceeded')
75 error.code = 'RATE_LIMIT'
76 error.retryAfter = rateLimit.retryAfter
77 error.resetAt = rateLimit.resetAt
78 throw error
79 }
80 }
81
82 // Try each provider in order
83 for (const provider of this.providers) {
84 const credentials = await provider.extractCredentials(req)
85
86 if (credentials) {
87 try {
88 const authResult = await provider.authenticate(credentials)
89
90 // Bearer tokens have permissions embedded, don't look up User
91 if (authResult.method === 'bearer-token') {
92 return {
93 userId: null,
94 identityId: authResult.identityId,
95 method: authResult.method,
96 username: authResult.credentials.username,
97 permissions: this.formatTokenPermissions(authResult),
98 verified: true
99 }
100 }
101
102 // For other methods, look up User via Identity
103 const user = await this.identityRegistry.getUserByIdentity(authResult.identityId)
104
105 if (!user && authResult.method === 'web-identity') {
106 // Web identity was auto-registered by provider, retry lookup
107 const newUser = await this.identityRegistry.getUserByIdentity(authResult.identityId)
108
109 if (newUser) {
110 return {
111 userId: newUser.userId,
112 identityId: authResult.identityId,
113 method: authResult.method,
114 permissions: this.formatUserPermissions(newUser, null),
115 user: newUser,
116 verified: true
117 }
118 }
119 }
120
121 if (!user) {
122 continue
123 }
124
125 // Auto-promote to admin if no admin exists (bootstrap case)
126 const hasAdmin = await this.identityRegistry._hasAnyAdmin()
127 if (!hasAdmin && user.globalPermissions && !user.globalPermissions.includes('admin')) {
128 await this.identityRegistry.upgradePermissions(user.userId, '*', ['read', 'write', 'admin'])
129 // Re-fetch upgraded user
130 const upgraded = await this.identityRegistry.getUser(user.userId)
131 if (upgraded) user = upgraded
132 }
133
134 return {
135 userId: user.userId,
136 identityId: authResult.identityId,
137 method: authResult.method,
138 permissions: this.formatUserPermissions(user, null),
139 user,
140 verified: true
141 }
142 } catch (err) {
143 continue
144 }
145 }
146 }
147
148 return null
149 }
150
151 /**
152 * Get permissions for a user
153 * @param {Object} user - User object
154 * @param {string} notebookName - Notebook name
155 * @returns {Object} Formatted permissions
156 */
157 formatUserPermissions(user, notebookName) {
158 // Check notebook-specific permissions
159 const notebookPerms = user.notebooks.find(r => r.name === notebookName || r.name === '*')
160
161 if (notebookPerms) {
162 return {
163 read: notebookPerms.permissions.includes('read') || notebookPerms.permissions.includes('admin'),
164 write: notebookPerms.permissions.includes('write') || notebookPerms.permissions.includes('admin'),
165 admin: notebookPerms.permissions.includes('admin'),
166 notebooks: [notebookPerms.name]
167 }
168 }
169
170 // Fall back to global permissions
171 return {
172 read: user.globalPermissions.includes('read') || user.globalPermissions.includes('admin'),
173 write: user.globalPermissions.includes('write') || user.globalPermissions.includes('admin'),
174 admin: user.globalPermissions.includes('admin'),
175 notebooks: ['*']
176 }
177 }
178
179 /**
180 * Format bearer token permissions
181 * @param {Object} authResult - Bearer token auth result
182 * @returns {Object} Formatted permissions
183 */
184 formatTokenPermissions(authResult) {
185 return {
186 read: authResult.permissions.includes('read') || authResult.permissions.includes('admin'),
187 write: authResult.permissions.includes('write') || authResult.permissions.includes('admin'),
188 admin: authResult.permissions.includes('admin'),
189 notebooks: authResult.notebooks
190 }
191 }
192
193 /**
194 * Check if auth has permission for action on notebook
195 * @param {Object} auth - Auth result from authenticate()
196 * @param {string} notebookName - Notebook name
197 * @param {string} action - 'read', 'write', or 'admin'
198 * @returns {boolean}
199 */
200 hasNotebookAccess(auth, notebookName, action = 'read') {
201 if (!auth || !auth.verified) return false
202
203 const perms = auth.permissions
204
205 // Admin can do anything
206 if (perms.admin) return true
207
208 // Check action
209 if (!perms[action]) return false
210
211 // Check notebook access
212 if (notebookName) {
213 return perms.notebooks.includes('*') || perms.notebooks.includes(notebookName)
214 }
215
216 return true
217 }
218
219 /**
220 * Middleware to authenticate and authorize requests
221 * @param {Object} req - HTTP request
222 * @param {string} action - Required action ('read', 'write', 'admin')
223 * @param {string} notebookName - Notebook name
224 * @returns {Promise<Object>} { authorized, auth, reason }
225 */
226 async authorizeRequest(req, action, notebookName = null) {
227 const auth = await this.authenticate(req)
228
229 if (!auth) {
230 return { authorized: false, auth: null, reason: 'Not authenticated' }
231 }
232
233 if (!this.hasNotebookAccess(auth, notebookName, action)) {
234 return { authorized: false, auth, reason: 'Insufficient permissions' }
235 }
236
237 return { authorized: true, auth, reason: null }
238 }
239
240 // ==================== TOKEN MANAGEMENT ====================
241
242 async createToken(options) {
243 return this.bearerProvider.createToken(options)
244 }
245
246 async validateToken(token) {
247 return this.bearerProvider.validateToken(token)
248 }
249
250 async revokeToken(tokenHash) {
251 return this.bearerProvider.revokeToken(tokenHash)
252 }
253
254 async listAllTokens() {
255 return this.bearerProvider.listAllTokens()
256 }
257
258 async getUserTokens(username) {
259 return this.bearerProvider.getUserTokens(username)
260 }
261
262 async createDefaultAdmin() {
263 const tokens = await this.listAllTokens()
264 if (tokens.length > 0) return null
265
266 return this.createToken({
267 username: 'admin',
268 description: 'Default admin token (generated on first start)',
269 permissions: ['read', 'write', 'admin'],
270 notebooks: ['*']
271 })
272 }
273
274 async getStatus() {
275 const tokens = await this.listAllTokens()
276 const activeTokens = tokens.filter(t => !t.expiresAt || t.expiresAt > Date.now())
277
278 return {
279 enabled: tokens.length > 0,
280 totalTokens: tokens.length,
281 activeTokens: activeTokens.length,
282 users: [...new Set(tokens.map(t => t.username))].length
283 }
284 }
285
286 // ==================== IDENTITY METHODS ====================
287
288 async getUserWithIdentities(identityId) {
289 const user = await this.identityRegistry.getUserByIdentity(identityId)
290 if (!user) return null
291
292 const identities = await this.identityRegistry.listUserIdentities(user.userId)
293
294 return { user, identities }
295 }
296
297 async upgradeUserPermissions(userId, notebookName, permissions) {
298 return this.identityRegistry.upgradePermissions(userId, notebookName, permissions)
299 }
300
301 async generateChallenge(options = {}) {
302 return await this.challengeManager.generate(options)
303 }
304
305 async verifyChallenge(challengeId, challenge) {
306 return await this.challengeManager.verify(challengeId, challenge)
307 }
308
309 async revokeIdentity(identityId, options = {}) {
310 return this.identityRegistry.revokeIdentity(identityId, options)
311 }
312
313 async restoreIdentity(identityId) {
314 return this.identityRegistry.restoreIdentity(identityId)
315 }
316
317 async listRevokedIdentities() {
318 return this.identityRegistry.listRevokedIdentities()
319 }
320
321 getClientIp(req) {
322 const socketIp = req.socket?.remoteAddress || 'unknown'
323
324 const isTrustedProxy = this.trustedProxies.some(ip => socketIp === ip || socketIp === '::ffff:' + ip)
325
326 if (isTrustedProxy) {
327 const forwarded = req.headers['x-forwarded-for']
328 if (forwarded) {
329 return forwarded.split(',')[0].trim()
330 }
331
332 const realIp = req.headers['x-real-ip']
333 if (realIp) {
334 return realIp
335 }
336 }
337
338 return socketIp
339 }
340
341 async getRateLimitStats() {
342 return await this.rateLimiter.stats()
343 }
344
345 async getChallengeStats() {
346 return await this.challengeManager.stats()
347 }
348
349 destroy() {
350 this.challengeManager.destroy()
351 this.rateLimiter.destroy()
352 }
353}
354
355/**
356 * Authorization levels
357 */
358export const Permissions = {
359 READ: 'read',
360 WRITE: 'write',
361 ADMIN: 'admin'
362}
363
364/**
365 * Helper to send auth error response
366 */
367export function sendAuthError(res, message = 'Unauthorized', statusCode = 401) {
368 res.statusCode = statusCode
369 res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"')
370 res.setHeader('Content-Type', 'application/json')
371 res.end(JSON.stringify({
372 error: message,
373 hint: 'Use Authorization: Bearer <token> or X-Wirecore-PublicKey header'
374 }))
375}
lib/auth/challenge-manager.js +339 -0
……@@ -0,0 +1,339 @@
1import crypto from 'hypercore-crypto'
2import b4a from 'b4a'
3
4/**
5 * ChallengeManager
6 *
7 * Manages server-side authentication challenges with persistent storage:
8 * - Generates random challenges
9 * - Tracks challenge expiry
10 * - Validates challenge freshness
11 * - Prevents replay attacks
12 * - Persistent state in Hyperbee
13 */
14export class ChallengeManager {
15 constructor(options = {}) {
16 this.ttl = options.ttl || 5 * 60 * 1000 // 5 minutes default
17
18 // Persistent storage (optional)
19 this.db = options.db || null
20
21 // In-memory cache for performance
22 this.cache = new Map()
23 this.cacheTimeout = options.cacheTimeout || 30 * 1000 // 30 seconds
24
25 // Clean up expired challenges every minute
26 this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000)
27 }
28
29 /**
30 * Generate new challenge
31 * @param {Object} options - { context } for logging
32 * @returns {Promise<Object>} { challengeId, challenge, expiresAt }
33 */
34 async generate(options = {}) {
35 const challengeId = crypto.randomBytes(16).toString('hex')
36 const challenge = crypto.randomBytes(32).toString('base64')
37 const createdAt = Date.now()
38 const expiresAt = createdAt + this.ttl
39
40 const record = {
41 challenge,
42 createdAt,
43 expiresAt,
44 used: false,
45 context: options.context,
46 cachedAt: Date.now()
47 }
48
49 // Save to cache
50 this.cache.set(challengeId, record)
51
52 // Save to storage
53 if (this.db) {
54 try {
55 const { cachedAt, ...data } = record
56 await this.db.put(`challenge:${challengeId}`, data)
57 } catch (err) {
58 console.warn('[Challenge] Failed to save challenge:', err.message)
59 }
60 }
61
62 return {
63 challengeId,
64 challenge,
65 expiresAt
66 }
67 }
68
69 /**
70 * Get challenge record (without consuming)
71 * @param {string} challengeId - Challenge ID
72 * @returns {Promise<Object|null>} Challenge record or null
73 */
74 async get(challengeId) {
75 const now = Date.now()
76
77 // Try cache first
78 let record = this.cache.get(challengeId)
79
80 // Load from storage if not in cache or cache expired
81 if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
82 record = await this.loadChallenge(challengeId)
83 if (record) {
84 record.cachedAt = now
85 this.cache.set(challengeId, record)
86 }
87 }
88
89 if (!record) {
90 return null
91 }
92
93 // Check expiry
94 if (now > record.expiresAt) {
95 await this.deleteChallenge(challengeId)
96 return null
97 }
98
99 // Check if already used
100 if (record.used) {
101 return null
102 }
103
104 return record
105 }
106
107 /**
108 * Consume challenge (mark as used)
109 * @param {string} challengeId - Challenge ID
110 * @returns {Promise<boolean>} True if consumed successfully
111 */
112 async consume(challengeId) {
113 // Atomically check and mark as used to prevent race conditions
114 let active = this.cache.get(challengeId)
115 if (active && !active.used && Date.now() <= active.expiresAt) {
116 active.used = true
117 this.cache.set(challengeId, active)
118 } else {
119 // Fall back to storage lookup
120 active = await this.loadChallenge(challengeId)
121 if (!active || active.used || Date.now() > active.expiresAt) return false
122 active.used = true
123 active.cachedAt = Date.now()
124 this.cache.set(challengeId, active)
125 }
126
127 // Update in storage
128 if (this.db) {
129 try {
130 const { cachedAt, ...data } = active
131 await this.db.put(`challenge:${challengeId}`, data)
132 } catch (err) {
133 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
134 }
135 }
136
137 return true
138 }
139
140 /**
141 * Verify challenge is valid and not expired
142 * @param {string} challengeId - Challenge ID
143 * @param {string} challenge - Challenge string to verify
144 * @returns {Promise<boolean>} True if valid and not expired
145 */
146 async verify(challengeId, challenge) {
147 const now = Date.now()
148
149 // Try cache first
150 let record = this.cache.get(challengeId)
151
152 // Load from storage if not in cache or cache expired
153 if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
154 record = await this.loadChallenge(challengeId)
155 if (record) {
156 record.cachedAt = now
157 this.cache.set(challengeId, record)
158 }
159 }
160
161 if (!record) {
162 console.warn('[Challenge] Unknown challenge:', challengeId)
163 return false
164 }
165
166 // Check expiry
167 if (now > record.expiresAt) {
168 console.warn('[Challenge] Expired challenge:', challengeId)
169 await this.deleteChallenge(challengeId)
170 return false
171 }
172
173 // Check if already used (prevent replay)
174 if (record.used) {
175 console.warn('[Challenge] Challenge already used:', challengeId)
176 return false
177 }
178
179 // Verify challenge matches
180 if (record.challenge !== challenge) {
181 console.warn('[Challenge] Challenge mismatch:', challengeId)
182 return false
183 }
184
185 // Mark as used
186 record.used = true
187 this.cache.set(challengeId, record)
188
189 // Update in storage
190 if (this.db) {
191 try {
192 const { cachedAt, ...data } = record
193 await this.db.put(`challenge:${challengeId}`, data)
194 } catch (err) {
195 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
196 }
197 }
198
199 return true
200 }
201
202 /**
203 * Load challenge from storage
204 * @param {string} challengeId - Challenge ID
205 * @returns {Promise<Object|null>} Challenge record or null
206 */
207 async loadChallenge(challengeId) {
208 if (!this.db) return null
209
210 try {
211 const entry = await this.db.get(`challenge:${challengeId}`)
212 return entry?.value || null
213 } catch (err) {
214 console.warn('[Challenge] Failed to load challenge:', err.message)
215 return null
216 }
217 }
218
219 /**
220 * Delete challenge from storage
221 * @param {string} challengeId - Challenge ID
222 * @returns {Promise<void>}
223 */
224 async deleteChallenge(challengeId) {
225 // Remove from cache
226 this.cache.delete(challengeId)
227
228 // Remove from storage
229 if (this.db) {
230 try {
231 await this.db.del(`challenge:${challengeId}`)
232 } catch (err) {
233 console.warn('[Challenge] Failed to delete challenge:', err.message)
234 }
235 }
236 }
237
238 /**
239 * Cleanup expired challenges
240 */
241 async cleanup() {
242 const now = Date.now()
243 let cleaned = 0
244
245 // Clean cache
246 for (const [challengeId, record] of this.cache.entries()) {
247 if (now > record.expiresAt) {
248 this.cache.delete(challengeId)
249 cleaned++
250 }
251 }
252
253 // Clean storage
254 if (this.db) {
255 try {
256 const toDelete = []
257
258 for await (const entry of this.db.createReadStream({
259 gte: 'challenge:',
260 lt: 'challenge:\xff'
261 })) {
262 const record = entry.value
263 if (record && now > record.expiresAt) {
264 toDelete.push(entry.key)
265 }
266 }
267
268 for (const key of toDelete) {
269 await this.db.del(key)
270 cleaned++
271 }
272 } catch (err) {
273 console.warn('[Challenge] Cleanup error:', err.message)
274 }
275 }
276
277 if (cleaned > 0) {
278 console.log(`[Challenge] Cleaned up ${cleaned} expired challenges`)
279 }
280 }
281
282 /**
283 * Destroy manager and stop cleanup
284 */
285 destroy() {
286 clearInterval(this.cleanupInterval)
287 this.cache.clear()
288 }
289
290 /**
291 * Get stats
292 */
293 async stats() {
294 let total = 0
295 let active = 0
296 let used = 0
297
298 // Count from cache
299 for (const record of this.cache.values()) {
300 total++
301 if (record.used) {
302 used++
303 } else {
304 active++
305 }
306 }
307
308 // Count from storage (if available)
309 if (this.db) {
310 try {
311 for await (const entry of this.db.createReadStream({
312 gte: 'challenge:',
313 lt: 'challenge:\xff'
314 })) {
315 const challengeId = entry.key.replace('challenge:', '')
316
317 // Skip if already counted in cache
318 if (this.cache.has(challengeId)) continue
319
320 total++
321 if (entry.value.used) {
322 used++
323 } else {
324 active++
325 }
326 }
327 } catch (err) {
328 console.warn('[Challenge] Stats error:', err.message)
329 }
330 }
331
332 return {
333 total,
334 cached: this.cache.size,
335 active,
336 used
337 }
338 }
339}
lib/auth/identity-registry.js +648 -0
……@@ -0,0 +1,648 @@
1import crypto from 'hypercore-crypto'
2
3/**
4 * Identity Registry
5 *
6 * Manages Users and Identities in Hyperbee.
7 *
8 * Key Concepts:
9 * - User: A person with permissions (can have multiple identities)
10 * - Identity: An authentication method (web keypair, keet-identity, token)
11 * - Linking: Multiple identities can link to the same user
12 *
13 * Storage Schema:
14 * - user:{userId} → User object
15 * - identity:{identityId} → Identity object
16 * - pubkey:{publicKey} → { identityId } (reverse lookup)
17 * - username:{username} → { userId } (reverse lookup, enforces uniqueness)
18 */
19
20const RESERVED_USERNAMES = [
21 'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth',
22 'health', 'status', 'login', 'logout', 'register', 'settings'
23]
24
25const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/
26export class IdentityRegistry {
27 constructor(hyperbee) {
28 this.db = hyperbee
29 }
30
31 /**
32 * Create a new User account
33 * @param {Object} data - User data
34 * @returns {Promise<Object>} Created user
35 */
36 async createUser(data = {}) {
37 const userId = crypto.randomBytes(16).toString('hex')
38
39 const user = {
40 userId,
41 username: data.username || null,
42 name: data.name || 'Anonymous',
43 email: data.email || null,
44 profilePicture: data.profilePicture || null,
45 globalPermissions: data.permissions || ['read'],
46 notebooks: data.notebooks || [{ name: '*', permissions: ['read'] }],
47 identities: [],
48 createdAt: Date.now(),
49 updatedAt: Date.now()
50 }
51
52 await this.db.put(`user:${userId}`, user)
53 return user
54 }
55
56 /**
57 * Create an Identity (auth method)
58 * @param {Object} data - Identity data
59 * @returns {Promise<Object>} Created identity
60 */
61 async createIdentity(data) {
62 const identity = {
63 identityId: data.identityId,
64 method: data.method,
65 userId: data.userId || null,
66 credentials: data.credentials || {},
67 createdAt: Date.now(),
68 updatedAt: Date.now()
69 }
70
71 await this.db.put(`identity:${data.identityId}`, identity)
72
73 // Create reverse lookups
74 if (data.method === 'web-identity' || data.method === 'keet-identity') {
75 const publicKey = data.credentials.publicKey || data.identityId
76 await this.db.put(`pubkey:${publicKey}`, { identityId: data.identityId })
77 }
78
79 return identity
80 }
81
82 /**
83 * Link an identity to a user
84 * @param {string} identityId - Identity ID
85 * @param {string} userId - User ID
86 * @returns {Promise<Object>} { user, identity }
87 */
88 async linkIdentity(identityId, userId) {
89 const identity = await this.getIdentity(identityId)
90 const user = await this.getUser(userId)
91
92 if (!identity || !user) {
93 throw new Error('Identity or User not found')
94 }
95
96 // Update identity
97 identity.userId = userId
98 identity.updatedAt = Date.now()
99 await this.db.put(`identity:${identityId}`, identity)
100
101 // Update user's identity list
102 if (!user.identities.includes(identityId)) {
103 user.identities.push(identityId)
104 user.updatedAt = Date.now()
105 await this.db.put(`user:${userId}`, user)
106 }
107
108 return { user, identity }
109 }
110
111 /**
112 * Auto-register: Create a new user account for a first-time identity.
113 *
114 * Every new identity gets its own separate user account.
115 * First user gets admin permissions, all subsequent get read-only.
116 * Admins can promote other users via POST /api/admin/promote.
117 *
118 * @param {Object} identityData - Identity data
119 * @returns {Promise<Object>} { user, identity }
120 */
121 async autoRegister(identityData) {
122 const hasUsers = await this._hasAnyUsers()
123
124 // First user gets admin, subsequent get read-only
125 const permissions = hasUsers ? ['read'] : ['read', 'write', 'admin']
126 const notebooks = hasUsers
127 ? [{ name: '*', permissions: ['read'] }]
128 : [{ name: '*', permissions: ['read', 'write', 'admin'] }]
129
130 const user = await this.createUser({
131 name: identityData.name || 'Anonymous',
132 permissions,
133 notebooks
134 })
135
136 const identity = await this.createIdentity({
137 identityId: identityData.identityId,
138 method: identityData.method,
139 userId: user.userId,
140 credentials: identityData.credentials
141 })
142
143 user.identities.push(identity.identityId)
144 await this.db.put(`user:${user.userId}`, user)
145
146 return { user, identity }
147 }
148
149 /**
150 * Find the first admin user
151 * @returns {Promise<Object|null>} Admin user or null
152 */
153 async _findAdminUser() {
154 for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
155 const user = entry.value
156 if (user.globalPermissions && user.globalPermissions.includes('admin')) {
157 return user
158 }
159 }
160 return null
161 }
162
163 /**
164 * Get user by any linked identity
165 * @param {string} identityId - Identity ID
166 * @returns {Promise<Object|null>} User or null
167 */
168 async getUserByIdentity(identityId) {
169 const identity = await this.getIdentity(identityId)
170 if (!identity || !identity.userId) {
171 return null
172 }
173
174 return this.getUser(identity.userId)
175 }
176
177 /**
178 * Get identity by ID
179 * @param {string} identityId - Identity ID
180 * @returns {Promise<Object|null>} Identity or null
181 */
182 async getIdentity(identityId) {
183 const entry = await this.db.get(`identity:${identityId}`)
184 return entry?.value
185 }
186
187 /**
188 * Get user by ID
189 * @param {string} userId - User ID
190 * @returns {Promise<Object|null>} User or null
191 */
192 async getUser(userId) {
193 const entry = await this.db.get(`user:${userId}`)
194 return entry?.value
195 }
196
197 /**
198 * Check if any users exist in the registry
199 * @returns {Promise<boolean>}
200 */
201 async _hasAnyUsers() {
202 for await (const _ of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff', limit: 1 })) {
203 return true
204 }
205 return false
206 }
207
208 /**
209 * Check if any admin user exists
210 * @returns {Promise<boolean>}
211 */
212 async _hasAnyAdmin() {
213 for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
214 const user = entry.value
215 if (user.globalPermissions && user.globalPermissions.includes('admin')) {
216 return true
217 }
218 }
219 return false
220 }
221
222 /**
223 * Lookup identity by public key (web-identity or keet-identity)
224 * @param {string} publicKey - Public key string
225 * @returns {Promise<Object|null>} Identity or null
226 */
227 async getIdentityByPublicKey(publicKey) {
228 const entry = await this.db.get(`pubkey:${publicKey}`)
229 if (!entry) return null
230
231 return this.getIdentity(entry.value.identityId)
232 }
233
234 /**
235 * Upgrade user permissions
236 * @param {string} userId - User ID
237 * @param {string} notebookName - Notebook name (or '*' for global)
238 * @param {Array<string>} newPermissions - Permissions to add
239 * @returns {Promise<Object>} Updated user
240 */
241 async upgradePermissions(userId, notebookName, newPermissions) {
242 const user = await this.getUser(userId)
243 if (!user) {
244 throw new Error('User not found')
245 }
246
247 // Find or create notebook-specific permissions
248 let notebook = user.notebooks.find(r => r.name === notebookName)
249
250 if (!notebook) {
251 notebook = { name: notebookName, permissions: [] }
252 user.notebooks.push(notebook)
253 }
254
255 // Merge permissions (add new, keep existing)
256 notebook.permissions = [...new Set([...notebook.permissions, ...newPermissions])]
257
258 // Also update globalPermissions when upgrading wildcard scope
259 if (notebookName === '*') {
260 user.globalPermissions = [...new Set([...user.globalPermissions, ...newPermissions])]
261 }
262
263 user.updatedAt = Date.now()
264
265 await this.db.put(`user:${userId}`, user)
266 return user
267 }
268
269 /**
270 * List all identities for a user
271 * @param {string} userId - User ID
272 * @returns {Promise<Array>} Array of identities
273 */
274 async listUserIdentities(userId) {
275 const user = await this.getUser(userId)
276 if (!user) return []
277
278 const identities = []
279 for (const identityId of user.identities) {
280 const identity = await this.getIdentity(identityId)
281 if (identity) {
282 identities.push(identity)
283 }
284 }
285
286 return identities
287 }
288
289 /**
290 * Unlink an identity from a user
291 * @param {string} identityId - Identity ID
292 * @returns {Promise<Object>} Updated user
293 */
294 async unlinkIdentity(identityId) {
295 const identity = await this.getIdentity(identityId)
296 if (!identity || !identity.userId) {
297 throw new Error('Identity not found or not linked')
298 }
299
300 const user = await this.getUser(identity.userId)
301
302 // Remove from user's identity list
303 user.identities = user.identities.filter(id => id !== identityId)
304 user.updatedAt = Date.now()
305 await this.db.put(`user:${user.userId}`, user)
306
307 // Clear userId from identity
308 identity.userId = null
309 identity.updatedAt = Date.now()
310 await this.db.put(`identity:${identityId}`, identity)
311
312 return user
313 }
314
315 /**
316 * Revoke an identity (mark as revoked, preventing authentication)
317 * @param {string} identityId - Identity ID
318 * @param {Object} options - { reason, revokedBy }
319 * @returns {Promise<Object>} Revoked identity
320 */
321 async revokeIdentity(identityId, options = {}) {
322 const identity = await this.getIdentity(identityId)
323 if (!identity) {
324 throw new Error('Identity not found')
325 }
326
327 if (identity.revoked) {
328 throw new Error('Identity already revoked')
329 }
330
331 // Mark as revoked
332 identity.revoked = true
333 identity.revokedAt = Date.now()
334 identity.revokedBy = options.revokedBy || 'system'
335 identity.revocationReason = options.reason || 'No reason provided'
336 identity.updatedAt = Date.now()
337
338 await this.db.put(`identity:${identityId}`, identity)
339
340 // Add to revocation list for quick checks
341 await this.db.put(`revoked:${identityId}`, {
342 identityId,
343 revokedAt: identity.revokedAt,
344 revokedBy: identity.revokedBy,
345 reason: identity.revocationReason
346 })
347
348 console.log(`[Identity] Revoked ${identity.method}: ${identityId.substring(0, 8)}... (${options.reason || 'no reason'})`)
349
350 return identity
351 }
352
353 /**
354 * Restore a revoked identity
355 * @param {string} identityId - Identity ID
356 * @returns {Promise<Object>} Restored identity
357 */
358 async restoreIdentity(identityId) {
359 const identity = await this.getIdentity(identityId)
360 if (!identity) {
361 throw new Error('Identity not found')
362 }
363
364 if (!identity.revoked) {
365 throw new Error('Identity not revoked')
366 }
367
368 // Remove revocation
369 delete identity.revoked
370 delete identity.revokedAt
371 delete identity.revokedBy
372 delete identity.revocationReason
373 identity.restoredAt = Date.now()
374 identity.updatedAt = Date.now()
375
376 await this.db.put(`identity:${identityId}`, identity)
377
378 // Remove from revocation list
379 await this.db.del(`revoked:${identityId}`)
380
381 console.log(`[Identity] Restored ${identity.method}: ${identityId.substring(0, 8)}...`)
382
383 return identity
384 }
385
386 /**
387 * Check if identity is revoked
388 * @param {string} identityId - Identity ID
389 * @returns {Promise<boolean>} True if revoked
390 */
391 async isRevoked(identityId) {
392 // Quick check in revocation list
393 const revoked = await this.db.get(`revoked:${identityId}`)
394 if (revoked) {
395 return true
396 }
397
398 // Fallback: Check identity object
399 const identity = await this.getIdentity(identityId)
400 return identity?.revoked === true
401 }
402
403 /**
404 * List all revoked identities
405 * @returns {Promise<Array>} Array of revoked identities
406 */
407 async listRevokedIdentities() {
408 const revoked = []
409
410 for await (const entry of this.db.createReadStream({ gte: 'revoked:', lt: 'revoked:\xff' })) {
411 revoked.push(entry.value)
412 }
413
414 return revoked
415 }
416
417 /**
418 * Validate username format
419 * @param {string} username - Username to validate
420 * @returns {{ valid: boolean, reason?: string }}
421 */
422 validateUsername(username) {
423 if (!username || typeof username !== 'string') {
424 return { valid: false, reason: 'Username is required' }
425 }
426
427 const lower = username.toLowerCase()
428 if (lower !== username) {
429 return { valid: false, reason: 'Username must be lowercase' }
430 }
431
432 if (username.length < 3 || username.length > 32) {
433 return { valid: false, reason: 'Username must be 3-32 characters' }
434 }
435
436 if (!USERNAME_REGEX.test(username)) {
437 return { valid: false, reason: 'Username must be lowercase alphanumeric with dashes, no leading/trailing dash or consecutive dashes' }
438 }
439
440 if (username.includes('--')) {
441 return { valid: false, reason: 'Username must not contain consecutive dashes' }
442 }
443
444 if (RESERVED_USERNAMES.includes(username)) {
445 return { valid: false, reason: 'Username is reserved' }
446 }
447
448 return { valid: true }
449 }
450
451 /**
452 * Set username for a user (unique, validated)
453 * @param {string} userId - User ID
454 * @param {string} username - Desired username
455 * @returns {Promise<Object>} Updated user
456 * @throws {Error} If username is invalid or taken
457 */
458 async setUsername(userId, username) {
459 const validation = this.validateUsername(username)
460 if (!validation.valid) {
461 const err = new Error(validation.reason)
462 err.code = 'VALIDATION_ERROR'
463 err.status = 400
464 throw err
465 }
466
467 const user = await this.getUser(userId)
468 if (!user) throw new Error('User not found')
469
470 // Check uniqueness
471 const existing = await this.db.get(`username:${username}`)
472 if (existing && existing.value.userId !== userId) {
473 const err = new Error('Username already taken')
474 err.code = 'CONFLICT'
475 err.status = 409
476 throw err
477 }
478
479 // Remove old username reverse lookup if changing
480 if (user.username && user.username !== username) {
481 await this.db.del(`username:${user.username}`)
482 }
483
484 // Set new username
485 user.username = username
486 user.updatedAt = Date.now()
487 await this.db.put(`user:${userId}`, user)
488 await this.db.put(`username:${username}`, { userId })
489
490 return user
491 }
492
493 /**
494 * Get user by username
495 * @param {string} username - Username
496 * @returns {Promise<Object|null>} User or null
497 */
498 async getUserByUsername(username) {
499 const entry = await this.db.get(`username:${username}`)
500 if (!entry) return null
501 return this.getUser(entry.value.userId)
502 }
503
504 /**
505 * Check if a username is available
506 * @param {string} username - Username to check
507 * @returns {Promise<boolean>} True if available
508 */
509 async isUsernameAvailable(username) {
510 const validation = this.validateUsername(username)
511 if (!validation.valid) return false
512
513 const entry = await this.db.get(`username:${username}`)
514 return !entry
515 }
516
517 /**
518 * Update user profile (name, picture)
519 * @param {string} userId - User ID
520 * @param {Object} updates - Fields to update
521 * @returns {Promise<Object>} Updated user
522 */
523 async updateUserProfile(userId, updates) {
524 const user = await this.getUser(userId)
525 if (!user) throw new Error('User not found')
526
527 if (updates.name !== undefined) {
528 user.name = updates.name
529 }
530
531 if (updates.theme !== undefined) {
532 user.theme = updates.theme
533 }
534
535 if (updates.profilePicture !== undefined) {
536 user.profilePicture = updates.profilePicture
537 }
538
539 user.updatedAt = Date.now()
540
541 await this.db.put(`user:${userId}`, user)
542 return user
543 }
544
545 /**
546 * List all users (admin only)
547 * @returns {Promise<Array>} Array of all users
548 */
549 async listAllUsers() {
550 const users = []
551
552 for await (const entry of this.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
553 users.push(entry.value)
554 }
555
556 return users
557 }
558
559 /**
560 * List all identities (admin only)
561 * @returns {Promise<Array>} Array of all identities
562 */
563 async listAllIdentities() {
564 const identities = []
565
566 for await (const entry of this.db.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
567 identities.push(entry.value)
568 }
569
570 return identities
571 }
572
573 /**
574 * Merge two user accounts
575 * Transfers all identities, permissions, and data from secondary to primary
576 * @param {string} primaryUserId - The account to keep
577 * @param {string} secondaryUserId - The account to merge in (will be marked as merged)
578 * @returns {Promise<Object>} { primary, merged, stats }
579 */
580 async mergeAccounts(primaryUserId, secondaryUserId) {
581 const primary = await this.getUser(primaryUserId)
582 const secondary = await this.getUser(secondaryUserId)
583
584 if (!primary) throw new Error('Primary user not found')
585 if (!secondary) throw new Error('Secondary user not found')
586 if (primaryUserId === secondaryUserId) throw new Error('Cannot merge account with itself')
587
588 const stats = {
589 identitiesTransferred: 0,
590 permissionsMerged: 0
591 }
592
593 // 1. Transfer all identities from secondary to primary
594 for (const identityId of secondary.identities) {
595 const identity = await this.getIdentity(identityId)
596 if (identity) {
597 if (identity.revoked) {
598 console.log(`[Identity] Skipping revoked identity during merge: ${identityId.substring(0, 8)}...`)
599 continue
600 }
601
602 identity.userId = primaryUserId
603 identity.updatedAt = Date.now()
604 await this.db.put(`identity:${identityId}`, identity)
605
606 if (!primary.identities.includes(identityId)) {
607 primary.identities.push(identityId)
608 }
609 stats.identitiesTransferred++
610 }
611 }
612
613 // 2. Merge permissions (union of both)
614 for (const secondaryNotebook of secondary.notebooks) {
615 const existingNotebook = primary.notebooks.find(r => r.name === secondaryNotebook.name)
616 if (existingNotebook) {
617 existingNotebook.permissions = [...new Set([...existingNotebook.permissions, ...secondaryNotebook.permissions])]
618 } else {
619 primary.notebooks.push({ ...secondaryNotebook })
620 }
621 stats.permissionsMerged++
622 }
623
624 // Merge global permissions
625 primary.globalPermissions = [...new Set([...primary.globalPermissions, ...secondary.globalPermissions])]
626
627 // 3. Update primary user
628 primary.updatedAt = Date.now()
629 await this.db.put(`user:${primaryUserId}`, primary)
630
631 // 4. Mark secondary as merged (keep for audit trail)
632 secondary.mergedInto = primaryUserId
633 secondary.mergedAt = Date.now()
634 secondary.identities = []
635 secondary.notebooks = []
636 secondary.globalPermissions = []
637 secondary.updatedAt = Date.now()
638 await this.db.put(`user:${secondaryUserId}`, secondary)
639
640 console.log(`[Identity] Merged user ${secondaryUserId} into ${primaryUserId}:`, stats)
641
642 return {
643 primary,
644 merged: secondary,
645 stats
646 }
647 }
648}
lib/auth/providers/bearer-token.js +232 -0
……@@ -0,0 +1,232 @@
1import { AuthProvider } from '../base-provider.js'
2import crypto from 'bare-crypto'
3
4/**
5 * BearerTokenProvider
6 *
7 * Implements token-based authentication:
8 * - Generates secure API tokens
9 * - Validates tokens from Authorization header or query params
10 * - Supports per-notebook or global permissions
11 * - Stores tokens in Hyperbee
12 */
13export class BearerTokenProvider extends AuthProvider {
14 constructor(registry) {
15 super(registry)
16 this.name = 'bearer-token'
17 this.tokenPrefix = 'token:'
18 this.userPrefix = 'user:'
19 }
20
21 /**
22 * Extract token from HTTP request
23 * Supports:
24 * - Authorization: Bearer <token>
25 * - Authorization: Token <token>
26 * - X-Auth-Token header
27 * - ?token=<token> query parameter
28 */
29 async extractCredentials(req) {
30 // Check Authorization header
31 const authHeader = req.headers.authorization || req.headers.Authorization
32 if (authHeader) {
33 const match = authHeader.match(/^(Bearer|Token)\s+(.+)$/i)
34 if (match) return match[2]
35 }
36
37 // Check X-Auth-Token header
38 const tokenHeader = req.headers['x-auth-token'] || req.headers['X-Auth-Token']
39 if (tokenHeader) return tokenHeader
40
41 // Check query parameter
42 if (req.url) {
43 try {
44 const url = new URL(req.url, `http://${req.headers.host}`)
45 const tokenParam = url.searchParams.get('token')
46 if (tokenParam) return tokenParam
47 } catch {
48 // Ignore URL parsing errors
49 }
50 }
51
52 return null
53 }
54
55 /**
56 * Authenticate using bearer token
57 * @param {string} token - Bearer token
58 * @returns {Promise<Object>} { identityId, method, verified, credentials, permissions, notebooks }
59 */
60 async authenticate(token) {
61 const tokenData = await this.validateToken(token)
62
63 if (!tokenData) {
64 throw new Error('Invalid or expired token')
65 }
66
67 // Check if token is revoked
68 const isRevoked = await this.registry.isRevoked(tokenData.tokenHash)
69 if (isRevoked) {
70 throw new Error('Bearer token has been revoked')
71 }
72
73 // For bearer tokens, the identityId is the tokenHash
74 return {
75 identityId: tokenData.tokenHash,
76 method: 'bearer-token',
77 verified: true,
78 credentials: {
79 username: tokenData.username,
80 tokenHash: tokenData.tokenHash
81 },
82 // Bearer tokens have permissions embedded (not from User object)
83 permissions: tokenData.permissions,
84 notebooks: tokenData.notebooks
85 }
86 }
87
88 /**
89 * Validate a token and return token data if valid
90 */
91 async validateToken(token) {
92 if (!token) return null
93
94 const tokenHash = this.hashToken(token)
95
96 try {
97 const entry = await this.registry.db.get(`${this.tokenPrefix}${tokenHash}`)
98 if (!entry) return null
99
100 const tokenData = entry.value
101
102 // Check expiration
103 if (tokenData.expiresAt && Date.now() > tokenData.expiresAt) {
104 return null
105 }
106
107 // Update usage stats (async, don't wait)
108 this.updateTokenUsage(tokenHash).catch(console.error)
109
110 return tokenData
111 } catch {
112 return null
113 }
114 }
115
116 /**
117 * Hash a token for storage
118 */
119 hashToken(token) {
120 return crypto.createHash('sha256').update(token).digest('hex')
121 }
122
123 /**
124 * Generate a secure random token
125 */
126 generateToken() {
127 const bytes = crypto.randomBytes(32)
128 return bytes.toString('hex')
129 }
130
131 /**
132 * Create a new token
133 */
134 async createToken(options = {}) {
135 const {
136 username,
137 description = 'API Token',
138 permissions = ['read'],
139 notebooks = ['*'],
140 expiresAt = null
141 } = options
142
143 if (!username) {
144 throw new Error('Username is required')
145 }
146
147 const token = this.generateToken()
148 const tokenHash = this.hashToken(token)
149
150 const tokenData = {
151 tokenHash,
152 username,
153 description,
154 permissions,
155 notebooks,
156 createdAt: Date.now(),
157 expiresAt,
158 lastUsedAt: null,
159 usageCount: 0
160 }
161
162 // Store in Hyperbee
163 await this.registry.db.put(`${this.tokenPrefix}${tokenHash}`, tokenData)
164
165 // Also add to user's token list
166 const userTokens = await this.getUserTokens(username)
167 userTokens.push(tokenHash)
168 await this.registry.db.put(`${this.userPrefix}${username}:tokens`, userTokens)
169
170 return {
171 token,
172 tokenHash,
173 ...tokenData
174 }
175 }
176
177 /**
178 * Update token usage statistics
179 */
180 async updateTokenUsage(tokenHash) {
181 const entry = await this.registry.db.get(`${this.tokenPrefix}${tokenHash}`)
182 if (entry) {
183 entry.value.lastUsedAt = Date.now()
184 entry.value.usageCount = (entry.value.usageCount || 0) + 1
185 await this.registry.db.put(`${this.tokenPrefix}${tokenHash}`, entry.value)
186 }
187 }
188
189 /**
190 * Get all tokens for a user
191 */
192 async getUserTokens(username) {
193 try {
194 const entry = await this.registry.db.get(`${this.userPrefix}${username}:tokens`)
195 return entry ? entry.value : []
196 } catch {
197 return []
198 }
199 }
200
201 /**
202 * Revoke a token
203 */
204 async revokeToken(tokenHash) {
205 const entry = await this.registry.db.get(`${this.tokenPrefix}${tokenHash}`)
206 if (!entry) return false
207
208 await this.registry.db.del(`${this.tokenPrefix}${tokenHash}`)
209
210 // Remove from user's token list
211 const username = entry.value.username
212 const userTokens = await this.getUserTokens(username)
213 const filtered = userTokens.filter(t => t !== tokenHash)
214 await this.registry.db.put(`${this.userPrefix}${username}:tokens`, filtered)
215
216 return true
217 }
218
219 /**
220 * List all tokens
221 */
222 async listAllTokens() {
223 const tokens = []
224 for await (const entry of this.registry.db.createReadStream({
225 gt: this.tokenPrefix,
226 lt: this.tokenPrefix + '\xff'
227 })) {
228 tokens.push(entry.value)
229 }
230 return tokens
231 }
232}
lib/auth/providers/keet-identity.js +149 -0
……@@ -0,0 +1,149 @@
1import { AuthProvider } from '../base-provider.js'
2import IdentityKey from 'keet-identity-key'
3import crypto from 'hypercore-crypto'
4import b4a from 'b4a'
5
6/**
7 * KeetIdentityProvider
8 *
9 * Authenticates users via Keet identity (BIP39 mnemonic-based):
10 * - Uses keet-identity-key package for BIP39 derivation
11 * - Looks for X-Keet-Signature header with signed challenge
12 * - Public key derived from BIP39 mnemonic
13 * - Used by hyper-wallet and other Keet-compatible apps
14 */
15export class KeetIdentityProvider extends AuthProvider {
16 constructor(registry) {
17 super(registry)
18 this.name = 'keet-identity'
19 }
20
21 /**
22 * Extract Keet signature from request headers
23 * @param {Object} req - HTTP request
24 * @returns {Promise<Object|null>} { publicKey, signature, challenge } or null
25 */
26 async extractCredentials(req) {
27 const keetSignature = req.headers['x-keet-signature']
28 const keetPublicKey = req.headers['x-keet-publickey']
29 const keetChallenge = req.headers['x-keet-challenge']
30
31 if (!keetSignature || !keetPublicKey || !keetChallenge) {
32 return null
33 }
34
35 return {
36 publicKey: keetPublicKey,
37 signature: keetSignature,
38 challenge: keetChallenge
39 }
40 }
41
42 /**
43 * Authenticate using Keet identity signature
44 * @param {Object} credentials - { publicKey, signature, challenge, challengeId }
45 * @returns {Promise<Object>} { identityId, method, verified }
46 */
47 async authenticate(credentials) {
48 const { publicKey, signature, challenge, challengeId } = credentials
49
50 // Verify signature
51 const isValid = await this.verifySignature(publicKey, signature, challenge)
52
53 if (!isValid) {
54 throw new Error('Invalid Keet signature')
55 }
56
57 // Check if identity exists
58 let identity = await this.registry.getIdentityByPublicKey(publicKey)
59
60 if (!identity) {
61 // Auto-register new Keet identity with read-only permissions
62 const { user, identity: newIdentity } = await this.registry.autoRegister({
63 identityId: publicKey,
64 method: 'keet-identity',
65 credentials: { publicKey }
66 })
67
68 console.log(`[KeetIdentity] Auto-registered: ${publicKey.substring(0, 8)}... → user ${user.userId}`)
69
70 return {
71 identityId: newIdentity.identityId,
72 method: 'keet-identity',
73 verified: true,
74 credentials: { publicKey }
75 }
76 }
77
78 // Check if identity is revoked
79 const isRevoked = await this.registry.isRevoked(identity.identityId)
80 if (isRevoked) {
81 throw new Error('Keet identity has been revoked')
82 }
83
84 return {
85 identityId: identity.identityId,
86 method: 'keet-identity',
87 verified: true,
88 credentials: { publicKey }
89 }
90 }
91
92 /**
93 * Verify Keet signature using ed25519
94 * @param {string} publicKey - Base64 public key
95 * @param {string} signature - Base64 signature
96 * @param {string} challenge - Challenge string
97 * @returns {Promise<boolean>} True if valid
98 */
99 async verifySignature(publicKey, signature, challenge) {
100 try {
101 // Convert from base64
102 const publicKeyBuf = b4a.from(publicKey, 'base64')
103 const signatureBuf = b4a.from(signature, 'base64')
104 const challengeBuf = b4a.from(challenge, 'utf8')
105
106 // Validate buffer sizes
107 if (publicKeyBuf.length !== 32) {
108 console.error('[KeetIdentity] Invalid public key size:', publicKeyBuf.length)
109 return false
110 }
111
112 if (signatureBuf.length !== 64) {
113 console.error('[KeetIdentity] Invalid signature size:', signatureBuf.length)
114 return false
115 }
116
117 // Verify ed25519 signature using hypercore-crypto
118 const isValid = crypto.verify(challengeBuf, signatureBuf, publicKeyBuf)
119
120 if (!isValid) {
121 console.warn('[KeetIdentity] Signature verification failed')
122 }
123
124 return isValid
125 } catch (err) {
126 console.error('[KeetIdentity] Signature verification error:', err)
127 return false
128 }
129 }
130
131 /**
132 * Generate new Keet identity from BIP39 mnemonic
133 * @param {Object} options - { mnemonic } (optional)
134 * @returns {Promise<Object>} { publicKey, mnemonic }
135 */
136 async generateIdentity(options = {}) {
137 // Generate new mnemonic if not provided
138 const mnemonic = options.mnemonic || IdentityKey.generateMnemonic()
139
140 // Derive identity from mnemonic
141 const identity = IdentityKey.fromMnemonic(mnemonic)
142 const publicKey = identity.publicKey
143
144 return {
145 publicKey: b4a.toString(publicKey, 'base64'), // 44 chars
146 mnemonic: mnemonic // BIP39 12/24 word mnemonic
147 }
148 }
149}
lib/auth/providers/web-identity.js +174 -0
……@@ -0,0 +1,174 @@
1import { AuthProvider } from '../base-provider.js'
2import { ANProto } from '../../anproto.js'
3
4/**
5 * WebIdentityProvider
6 *
7 * Implements wirecore-style browser identity with signature verification:
8 * - Auto-generates ANProto ed25519 keypair on first visit
9 * - Stores keypair in browser localStorage
10 * - Uses challenge-response with ed25519 signatures
11 * - New identities get read-only permissions by default
12 */
13const DEBUG = typeof process !== 'undefined' && process.env?.DEBUG
14
15export class WebIdentityProvider extends AuthProvider {
16 constructor(registry, challengeManager) {
17 super(registry)
18 this.name = 'web-identity'
19 this.challengeManager = challengeManager
20 }
21
22 /**
23 * Extract credentials from request headers or URL parameters
24 * @param {Object} req - HTTP request
25 * @returns {Promise<Object|null>} Credentials or null
26 */
27 async extractCredentials(req) {
28 let publicKey = req.headers['x-wirecore-publickey']
29 let signature = req.headers['x-wirecore-signature']
30 let challengeId = req.headers['x-wirecore-challenge']
31
32 // Also check URL parameters (for page navigations)
33 if (!publicKey && req.url) {
34 try {
35 const url = new URL(req.url, 'http://localhost')
36 publicKey = url.searchParams.get('publicKey')
37 signature = url.searchParams.get('signature')
38 challengeId = url.searchParams.get('challengeId')
39 } catch (err) {
40 if (DEBUG) console.error('[WebIdentity] URL parsing error:', err.message)
41 }
42 }
43
44 // If no signature/challenge, fall back to public key only (for backwards compat)
45 // SECURITY: Legacy mode only allows EXISTING identities, not auto-registration
46 if (!signature && !challengeId) {
47 if (!publicKey || publicKey.length !== 44) return null
48 if (DEBUG) console.warn('[WebIdentity] Legacy auth (no signature) for:', publicKey.substring(0, 8) + '...')
49 return { publicKey, legacy: true }
50 }
51
52 // New flow: require all three
53 if (!publicKey || !signature || !challengeId) {
54 return null
55 }
56
57 // Validate format
58 if (publicKey.length !== 44) {
59 if (DEBUG) console.warn('[WebIdentity] Invalid public key format')
60 return null
61 }
62
63 if (signature.length !== 88) {
64 if (DEBUG) console.warn('[WebIdentity] Invalid signature format')
65 return null
66 }
67
68 return { publicKey, signature, challengeId }
69 }
70
71 /**
72 * Authenticate using signed challenge
73 * @param {Object} credentials - { publicKey, signature, challengeId, legacy }
74 * @returns {Promise<Object>} { identityId, method, verified, credentials }
75 */
76 async authenticate(credentials) {
77 const { publicKey, signature, challengeId, legacy } = credentials
78
79 if (legacy) {
80 return this.authenticateLegacy(publicKey)
81 }
82
83 // Verify challenge exists
84 const challenge = await this.challengeManager.get(challengeId)
85 if (!challenge) {
86 throw new Error('Invalid or expired challenge')
87 }
88
89 // Verify signature
90 const isValid = ANProto.verify(challenge.challenge, signature, publicKey)
91 if (!isValid) {
92 throw new Error('Invalid signature')
93 }
94
95 // Consume challenge (prevent replay)
96 await this.challengeManager.consume(challengeId)
97
98 // Check if identity exists
99 let identity = await this.registry.getIdentityByPublicKey(publicKey)
100
101 if (!identity) {
102 // Auto-register new identity
103 const { user, identity: newIdentity } = await this.registry.autoRegister({
104 identityId: publicKey,
105 method: 'web-identity',
106 credentials: { publicKey }
107 })
108
109 if (DEBUG) console.log(`[WebIdentity] Auto-registered: ${publicKey.substring(0, 8)}...`)
110
111 return {
112 identityId: newIdentity.identityId,
113 method: 'web-identity',
114 verified: true,
115 credentials: { publicKey }
116 }
117 }
118
119 // Check if identity is revoked
120 const isRevoked = await this.registry.isRevoked(identity.identityId)
121 if (isRevoked) {
122 throw new Error('Web identity has been revoked')
123 }
124
125 return {
126 identityId: identity.identityId,
127 method: 'web-identity',
128 verified: true,
129 credentials: { publicKey }
130 }
131 }
132
133 /**
134 * Legacy authentication (backwards compat - no signature)
135 *
136 * SECURITY FIX: Legacy mode only allows EXISTING identities.
137 * New identities MUST use signed authentication to prevent
138 * anyone from claiming arbitrary public keys.
139 *
140 * @param {string} publicKey - Public key
141 * @returns {Promise<Object>}
142 */
143 async authenticateLegacy(publicKey) {
144 // Check if identity exists
145 let identity = null
146 try {
147 identity = await this.registry.getIdentityByPublicKey(publicKey)
148 } catch (err) {
149 if (DEBUG) console.error('[WebIdentity] getIdentityByPublicKey error:', err.message)
150 }
151
152 if (!identity) {
153 throw new Error('Signature required for new identities. Please refresh the page to enable signed authentication.')
154 }
155
156 // Check if identity is revoked
157 const isRevoked = await this.registry.isRevoked(identity.identityId)
158 if (isRevoked) {
159 throw new Error('Web identity has been revoked')
160 }
161
162 // Legacy auth allowed for existing identities only
163 // Mark as unverified to distinguish from signed auth
164
165 return {
166 identityId: identity.identityId,
167 method: 'web-identity',
168 verified: false, // Mark as unverified since no signature
169 legacy: true, // Flag legacy mode for audit/logging
170 credentials: { publicKey }
171 }
172 }
173
174}
lib/auth/rate-limiter.js +272 -0
……@@ -0,0 +1,272 @@
1/**
2 * RateLimiter
3 *
4 * Token bucket rate limiting with persistent storage:
5 * - Per-IP rate limiting
6 * - Per-identity rate limiting
7 * - Configurable limits per operation type
8 * - Automatic cleanup of expired buckets
9 * - Persistent state in Hyperbee
10 */
11export class RateLimiter {
12 constructor(options = {}) {
13 this.limits = options.limits || {
14 // Format: { tokens, refillRate, refillInterval }
15 'auth': { tokens: 10, refillRate: 2, refillInterval: 60 * 1000 }, // 10 attempts, +2/min
16 'api': { tokens: 100, refillRate: 20, refillInterval: 60 * 1000 }, // 100 requests, +20/min
17 'notes': { tokens: 50, refillRate: 10, refillInterval: 60 * 1000 }, // 50 note ops, +10/min
18 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 } // 5 generates, +1/hr
19 }
20
21 // Persistent storage (optional)
22 this.db = options.db || null
23
24 // In-memory cache for performance
25 this.cache = new Map()
26 this.cacheTimeout = options.cacheTimeout || 10 * 1000 // 10 seconds
27
28 // Cleanup expired buckets every 5 minutes
29 this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000)
30 }
31
32 /**
33 * Check if request is allowed
34 * @param {string} key - Unique key (IP or identityId)
35 * @param {string} operation - Operation type (auth, api, git)
36 * @param {number} cost - Token cost (default 1)
37 * @returns {Promise<Object>} { allowed, remaining, resetAt }
38 */
39 async check(key, operation = 'api', cost = 1) {
40 const limit = this.limits[operation]
41 if (!limit) {
42 console.warn(`[RateLimit] Unknown operation: ${operation}`)
43 return { allowed: true, remaining: Infinity, resetAt: null }
44 }
45
46 const bucketKey = `${operation}:${key}`
47
48 // Try cache first
49 let bucket = this.cache.get(bucketKey)
50 const now = Date.now()
51
52 // Load from storage if not in cache or cache expired
53 if (!bucket || (bucket.cachedAt && now - bucket.cachedAt > this.cacheTimeout)) {
54 bucket = await this.loadBucket(bucketKey, limit)
55 bucket.cachedAt = now
56 this.cache.set(bucketKey, bucket)
57 }
58
59 // Refill tokens based on time elapsed
60 const elapsed = now - bucket.lastRefill
61 const refills = Math.floor(elapsed / bucket.limit.refillInterval)
62
63 if (refills > 0) {
64 bucket.tokens = Math.min(
65 bucket.limit.tokens,
66 bucket.tokens + (refills * bucket.limit.refillRate)
67 )
68 bucket.lastRefill = now
69 }
70
71 // Check if enough tokens
72 if (bucket.tokens >= cost) {
73 bucket.tokens -= cost
74
75 // Update cache and storage
76 this.cache.set(bucketKey, bucket)
77 await this.saveBucket(bucketKey, bucket)
78
79 return {
80 allowed: true,
81 remaining: bucket.tokens,
82 resetAt: bucket.lastRefill + bucket.limit.refillInterval
83 }
84 }
85
86 // Rate limited
87 return {
88 allowed: false,
89 remaining: 0,
90 resetAt: bucket.lastRefill + bucket.limit.refillInterval,
91 retryAfter: Math.ceil((bucket.limit.refillInterval - elapsed) / 1000)
92 }
93 }
94
95 /**
96 * Load bucket from storage
97 * @param {string} bucketKey - Bucket key
98 * @param {Object} limit - Limit configuration
99 * @returns {Promise<Object>} Bucket
100 */
101 async loadBucket(bucketKey, limit) {
102 if (!this.db) {
103 // No persistent storage - create new bucket
104 return {
105 tokens: limit.tokens,
106 lastRefill: Date.now(),
107 limit
108 }
109 }
110
111 try {
112 const entry = await this.db.get(`ratelimit:${bucketKey}`)
113 if (entry && entry.value) {
114 return {
115 ...entry.value,
116 limit // Restore limit config
117 }
118 }
119 } catch (err) {
120 console.warn('[RateLimit] Failed to load bucket:', err.message)
121 }
122
123 // Create new bucket
124 return {
125 tokens: limit.tokens,
126 lastRefill: Date.now(),
127 limit
128 }
129 }
130
131 /**
132 * Save bucket to storage
133 * @param {string} bucketKey - Bucket key
134 * @param {Object} bucket - Bucket data
135 * @returns {Promise<void>}
136 */
137 async saveBucket(bucketKey, bucket) {
138 if (!this.db) return
139
140 try {
141 // Don't save limit config (restore from constructor)
142 const { limit, cachedAt, ...data } = bucket
143 await this.db.put(`ratelimit:${bucketKey}`, data)
144 } catch (err) {
145 console.warn('[RateLimit] Failed to save bucket:', err.message)
146 }
147 }
148
149 /**
150 * Reset bucket for key
151 * @param {string} key - Unique key
152 * @param {string} operation - Operation type
153 */
154 async reset(key, operation) {
155 const bucketKey = `${operation}:${key}`
156
157 // Clear from cache
158 this.cache.delete(bucketKey)
159
160 // Delete from storage
161 if (this.db) {
162 try {
163 await this.db.del(`ratelimit:${bucketKey}`)
164 } catch (err) {
165 console.warn('[RateLimit] Failed to delete bucket:', err.message)
166 }
167 }
168 }
169
170 /**
171 * Cleanup stale buckets (no activity for 1 hour)
172 */
173 async cleanup() {
174 const now = Date.now()
175 const staleThreshold = 60 * 60 * 1000 // 1 hour
176 let cleaned = 0
177
178 // Clean cache
179 for (const [key, bucket] of this.cache.entries()) {
180 if (now - bucket.lastRefill > staleThreshold) {
181 this.cache.delete(key)
182 cleaned++
183 }
184 }
185
186 // Clean storage
187 if (this.db) {
188 try {
189 const toDelete = []
190
191 for await (const entry of this.db.createReadStream({
192 gte: 'ratelimit:',
193 lt: 'ratelimit:\xff'
194 })) {
195 const bucket = entry.value
196 if (bucket && now - bucket.lastRefill > staleThreshold) {
197 toDelete.push(entry.key)
198 }
199 }
200
201 for (const key of toDelete) {
202 await this.db.del(key)
203 cleaned++
204 }
205 } catch (err) {
206 console.warn('[RateLimit] Cleanup error:', err.message)
207 }
208 }
209
210 if (cleaned > 0) {
211 console.log(`[RateLimit] Cleaned up ${cleaned} stale buckets`)
212 }
213 }
214
215 /**
216 * Get stats
217 */
218 async stats() {
219 const byOperation = {}
220 let total = 0
221
222 // Count from cache
223 for (const [key, bucket] of this.cache.entries()) {
224 const operation = key.split(':')[0]
225 if (!byOperation[operation]) {
226 byOperation[operation] = { count: 0, totalTokens: 0 }
227 }
228 byOperation[operation].count++
229 byOperation[operation].totalTokens += bucket.tokens
230 total++
231 }
232
233 // Count from storage (if available)
234 if (this.db) {
235 try {
236 for await (const entry of this.db.createReadStream({
237 gte: 'ratelimit:',
238 lt: 'ratelimit:\xff'
239 })) {
240 const key = entry.key.replace('ratelimit:', '')
241 const operation = key.split(':')[0]
242
243 // Skip if already counted in cache
244 if (this.cache.has(key)) continue
245
246 if (!byOperation[operation]) {
247 byOperation[operation] = { count: 0, totalTokens: 0 }
248 }
249 byOperation[operation].count++
250 byOperation[operation].totalTokens += (entry.value.tokens || 0)
251 total++
252 }
253 } catch (err) {
254 console.warn('[RateLimit] Stats error:', err.message)
255 }
256 }
257
258 return {
259 total,
260 cached: this.cache.size,
261 byOperation
262 }
263 }
264
265 /**
266 * Destroy limiter and stop cleanup
267 */
268 destroy() {
269 clearInterval(this.cleanupInterval)
270 this.cache.clear()
271 }
272}
lib/errors.js +83 -0
……@@ -0,0 +1,83 @@
1/**
2 * Error Classes for hyper-booking
3 * Adapted from hypernote/pear-git pattern.
4 */
5
6export class AppError extends Error {
7 constructor (message, code, status = 500) {
8 super(message)
9 this.name = 'AppError'
10 this.code = code
11 this.status = status
12 this.timestamp = Date.now()
13 if (Error.captureStackTrace) {
14 Error.captureStackTrace(this, this.constructor)
15 }
16 }
17
18 toJSON () {
19 return { error: this.message, code: this.code, status: this.status }
20 }
21}
22
23export class NotFoundError extends AppError {
24 constructor (message = 'Not found', resource = null) {
25 super(message, 'NOT_FOUND', 404)
26 this.name = 'NotFoundError'
27 this.resource = resource
28 }
29
30 toJSON () {
31 return { ...super.toJSON(), resource: this.resource }
32 }
33}
34
35export class ForbiddenError extends AppError {
36 constructor (message = 'Forbidden', requiredPermission = null) {
37 super(message, 'FORBIDDEN', 403)
38 this.name = 'ForbiddenError'
39 this.requiredPermission = requiredPermission
40 }
41}
42
43export class UnauthorizedError extends AppError {
44 constructor (message = 'Authentication required') {
45 super(message, 'UNAUTHORIZED', 401)
46 this.name = 'UnauthorizedError'
47 }
48}
49
50export class ValidationError extends AppError {
51 constructor (message, field = null) {
52 super(message, 'VALIDATION_ERROR', 400)
53 this.name = 'ValidationError'
54 this.field = field
55 }
56}
57
58export class ConflictError extends AppError {
59 constructor (message = 'Resource conflict') {
60 super(message, 'CONFLICT', 409)
61 this.name = 'ConflictError'
62 }
63}
64
65export class RateLimitError extends AppError {
66 constructor (message = 'Rate limit exceeded', retryAfter = null) {
67 super(message, 'RATE_LIMIT', 429)
68 this.name = 'RateLimitError'
69 this.retryAfter = retryAfter
70 }
71}
72
73export function isAppError (err) {
74 return err instanceof AppError
75}
76
77export function wrapError (err, defaultMessage = 'An error occurred') {
78 if (err instanceof AppError) return err
79 const wrapped = new AppError(err.message || defaultMessage, 'INTERNAL_ERROR', 500)
80 wrapped.originalError = err
81 wrapped.stack = err.stack
82 return wrapped
83}
lib/response.js +84 -0
……@@ -0,0 +1,84 @@
1/**
2 * Response Helpers — standardized HTTP response utilities.
3 * Same pattern as hypernote.
4 */
5
6export function json (res, data, status = 200) {
7 res.statusCode = status
8 res.setHeader('Content-Type', 'application/json')
9 res.setHeader('X-Content-Type-Options', 'nosniff')
10 res.end(JSON.stringify(data))
11}
12
13export function html (res, content, status = 200) {
14 res.statusCode = status
15 res.setHeader('Content-Type', 'text/html')
16 res.setHeader('X-Frame-Options', 'DENY')
17 res.setHeader('X-Content-Type-Options', 'nosniff')
18 res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
19 res.end(content)
20}
21
22export function text (res, content, status = 200) {
23 res.statusCode = status
24 res.setHeader('Content-Type', 'text/plain')
25 res.end(content)
26}
27
28export function error (res, message, status = 500) {
29 res.statusCode = status
30 res.setHeader('Content-Type', 'application/json')
31 res.end(JSON.stringify({ error: message }))
32}
33
34export function notFound (res, message = 'Not found') {
35 error(res, message, 404)
36}
37
38export function badRequest (res, message = 'Bad request') {
39 error(res, message, 400)
40}
41
42export function unauthorized (res, message = 'Unauthorized') {
43 res.statusCode = 401
44 res.setHeader('WWW-Authenticate', 'Bearer realm="hyper-booking"')
45 res.setHeader('Content-Type', 'application/json')
46 res.end(JSON.stringify({ error: message }))
47}
48
49const MAX_BODY_SIZE = 1024 * 1024 // 1MB
50
51export async function readBody (req) {
52 let body = ''
53 let size = 0
54 for await (const chunk of req) {
55 size += chunk.length
56 if (size > MAX_BODY_SIZE) {
57 throw new Error('Request body too large (max 1MB)')
58 }
59 body += chunk.toString()
60 }
61 return body
62}
63
64export async function readJson (req) {
65 const body = await readBody(req)
66 if (!body) return {}
67 return JSON.parse(body)
68}
69
70export function escapeHtml (text) {
71 if (!text) return ''
72 return String(text)
73 .replace(/&/g, '&amp;')
74 .replace(/</g, '&lt;')
75 .replace(/>/g, '&gt;')
76 .replace(/"/g, '&quot;')
77 .replace(/'/g, '&#039;')
78}
79
80export function redirect (res, url, status = 302) {
81 res.statusCode = status
82 res.setHeader('Location', url)
83 res.end()
84}
lib/router.js +117 -0
……@@ -0,0 +1,117 @@
1/**
2 * Router — pattern-based HTTP router.
3 * Same pattern as hypernote/pear-git.
4 */
5
6import { isAppError } from './errors.js'
7
8export class Router {
9 constructor (server) {
10 this.server = server
11 this.routes = []
12 }
13
14 get (pattern, ...handlers) {
15 this.routes.push({ method: 'GET', pattern, handlers })
16 return this
17 }
18
19 post (pattern, ...handlers) {
20 this.routes.push({ method: 'POST', pattern, handlers })
21 return this
22 }
23
24 put (pattern, ...handlers) {
25 this.routes.push({ method: 'PUT', pattern, handlers })
26 return this
27 }
28
29 delete (pattern, ...handlers) {
30 this.routes.push({ method: 'DELETE', pattern, handlers })
31 return this
32 }
33
34 all (pattern, ...handlers) {
35 this.routes.push({ method: '*', pattern, handlers })
36 return this
37 }
38
39 patternToRegex (pattern) {
40 const regexStr = pattern
41 .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
42 .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>[^/]+)')
43 .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>.+)')
44 return new RegExp('^' + regexStr + '$')
45 }
46
47 match (route, method, url) {
48 if (route.method !== '*' && route.method !== method) return null
49 if (!route.regex) route.regex = this.patternToRegex(route.pattern)
50 const match = url.match(route.regex)
51 if (!match) return null
52 const params = match.groups || {}
53 for (const key in params) {
54 if (params[key]) {
55 try { params[key] = decodeURIComponent(params[key]) } catch {}
56 }
57 }
58 return { params }
59 }
60
61 normalizeUrl (url) {
62 const queryIndex = url.indexOf('?')
63 let pathOnly = queryIndex >= 0 ? url.substring(0, queryIndex) : url
64 if (this.server.pathPrefix && pathOnly.startsWith(this.server.pathPrefix)) {
65 pathOnly = pathOnly.substring(this.server.pathPrefix.length) || '/'
66 }
67 if (pathOnly.length > 1 && pathOnly.endsWith('/')) {
68 pathOnly = pathOnly.slice(0, -1)
69 }
70 return pathOnly
71 }
72
73 async dispatch (req, res) {
74 const url = this.normalizeUrl(req.url)
75 const method = req.method
76
77 for (const route of this.routes) {
78 const match = this.match(route, method, url)
79 if (match) {
80 req.params = match.params
81 let skipped = false
82 for (const handler of route.handlers) {
83 try {
84 const result = await handler(req, res, this.server)
85 if (result === false) { skipped = true; break }
86 if (res.writableEnded) return true
87 } catch (err) {
88 this.handleError(err, req, res)
89 return true
90 }
91 }
92 if (!skipped) return true
93 }
94 }
95 return false
96 }
97
98 handleError (err, req, res) {
99 if (res.writableEnded) return
100
101 if (isAppError(err)) {
102 res.statusCode = err.status || 500
103 res.setHeader('Content-Type', 'application/json')
104 if (err.status === 401) res.setHeader('WWW-Authenticate', 'Bearer realm="hyper-booking"')
105 if (err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter))
106 res.end(JSON.stringify(err.toJSON()))
107 if (err.status >= 500) {
108 console.error(`[Router] ${err.status}: ${err.message}`)
109 }
110 } else {
111 console.error('[Router] Unhandled:', err)
112 res.statusCode = 500
113 res.setHeader('Content-Type', 'application/json')
114 res.end(JSON.stringify({ error: 'Internal server error', code: 'INTERNAL_ERROR', status: 500 }))
115 }
116 }
117}
lib/routes/auth-helpers.js +20 -0
……@@ -0,0 +1,20 @@
1/**
2 * Shared auth helpers for route handlers.
3 */
4
5import { UnauthorizedError, ForbiddenError } from '../errors.js'
6
7export async function requireAuth (req, server, action, resource) {
8 const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, resource)
9 if (!auth) throw new UnauthorizedError('Authentication required')
10 if (!authorized) throw new ForbiddenError(reason, action)
11 return auth
12}
13
14export async function optionalAuth (req, server) {
15 try {
16 return await server.authManager.authenticate(req)
17 } catch {
18 return null
19 }
20}
lib/routes/auth.js +58 -0
……@@ -0,0 +1,58 @@
1/**
2 * Auth routes — challenge endpoint + identity management.
3 */
4
5import { json, readJson } from '../response.js'
6
7export function registerAuthRoutes (router, server) {
8 // Get challenge for signing
9 router.get('/api/auth/challenge', async (req, res) => {
10 const challenge = await server.authManager.challengeManager.generate()
11 json(res, challenge)
12 })
13
14 // Create identity (register with username)
15 router.post('/api/auth/identity', async (req, res) => {
16 const body = await readJson(req)
17 const { publicKey, username } = body
18
19 if (!publicKey) {
20 json(res, { error: 'publicKey required' }, 400)
21 return
22 }
23
24 // Auto-register via WebIdentityProvider happens on first authenticated request.
25 // This endpoint allows setting a username.
26 if (username) {
27 const auth = await server.authManager.authenticate(req)
28 if (auth) {
29 await server.authManager.identityRegistry.setUsername(auth.userId, username)
30 json(res, { ok: true, username })
31 return
32 }
33 }
34
35 json(res, { ok: true })
36 })
37
38 // Get current user info
39 router.get('/api/auth/me', async (req, res) => {
40 const auth = await server.authManager.authenticate(req)
41 if (!auth) {
42 json(res, { authenticated: false })
43 return
44 }
45
46 const user = auth.user || null
47 json(res, {
48 authenticated: true,
49 userId: auth.userId,
50 method: auth.method,
51 permissions: auth.permissions,
52 username: user ? user.username : auth.username,
53 role: auth.permissions && auth.permissions.global
54 ? (auth.permissions.global.includes('admin') ? 'admin' : 'user')
55 : 'user'
56 })
57 })
58}
lib/routes/availability.js +209 -0
……@@ -0,0 +1,209 @@
1/**
2 * Availability routes — provider schedule + slot calculation.
3 */
4
5import { json, readJson, notFound } from '../response.js'
6import { requireAuth } from './auth-helpers.js'
7
8export function registerAvailabilityRoutes (router, server) {
9 // Get provider availability (public — needed for slot calculation)
10 router.get('/api/providers/:slug/availability', async (req, res) => {
11 const providerIdEntry = await server.db.get(`provider-by-slug:${req.params.slug}`)
12 if (!providerIdEntry) return notFound(res, 'Provider not found')
13
14 const entry = await server.db.get(`availability:${providerIdEntry.value}`)
15 if (!entry) {
16 json(res, { weekly: {}, bufferMinutes: 15, advanceBooking: { minHours: 24, maxDays: 60 } })
17 return
18 }
19
20 json(res, entry.value)
21 })
22
23 // Update availability (provider)
24 router.put('/api/availability', async (req, res) => {
25 const auth = await requireAuth(req, server, 'write')
26 const body = await readJson(req)
27
28 const { providerId, weekly, bufferMinutes, advanceBooking, cancellation, reschedule, noShow, timezone } = body
29 if (!providerId) {
30 json(res, { error: 'providerId required' }, 400)
31 return
32 }
33
34 // Verify ownership
35 const providerEntry = await server.db.get(`provider:${providerId}`)
36 if (!providerEntry) return notFound(res, 'Provider not found')
37 if (providerEntry.value.userId !== auth.userId &&
38 !auth.permissions.global.includes('admin')) {
39 json(res, { error: 'Not your provider' }, 403)
40 return
41 }
42
43 const availability = {
44 providerId,
45 timezone: timezone || providerEntry.value.timezone || 'America/New_York',
46 weekly: weekly || {},
47 bufferMinutes: bufferMinutes != null ? bufferMinutes : 15,
48 advanceBooking: advanceBooking || { minHours: 24, maxDays: 60 },
49 cancellation: cancellation || { windowHours: 24, refundPolicy: 'full' },
50 reschedule: reschedule || { windowHours: 24, maxPerBooking: 2 },
51 noShow: noShow || { penalty: 'forfeit' },
52 updatedAt: Date.now()
53 }
54
55 await server.db.put(`availability:${providerId}`, availability)
56 json(res, availability)
57 })
58
59 // Update date overrides (provider)
60 router.put('/api/availability/overrides', async (req, res) => {
61 const auth = await requireAuth(req, server, 'write')
62 const body = await readJson(req)
63 const { providerId, overrides } = body
64
65 if (!providerId || !Array.isArray(overrides)) {
66 json(res, { error: 'providerId and overrides array required' }, 400)
67 return
68 }
69
70 await server.db.put(`availability-overrides:${providerId}`, overrides)
71 json(res, { ok: true, count: overrides.length })
72 })
73
74 // Get available slots for a date range (public)
75 router.get('/api/providers/:slug/slots', async (req, res) => {
76 const providerIdEntry = await server.db.get(`provider-by-slug:${req.params.slug}`)
77 if (!providerIdEntry) return notFound(res, 'Provider not found')
78
79 const providerId = providerIdEntry.value
80 const url = new URL('http://x' + req.url)
81 const date = url.searchParams.get('date')
82 const weekOf = url.searchParams.get('weekOf')
83 const duration = parseInt(url.searchParams.get('duration') || '60')
84 const tz = url.searchParams.get('tz') || 'UTC'
85
86 if (!date && !weekOf) {
87 json(res, { error: 'date or weekOf query param required' }, 400)
88 return
89 }
90
91 if (weekOf) {
92 // Return slots for 7 days
93 const days = {}
94 for (let i = 0; i < 7; i++) {
95 const d = addDays(weekOf, i)
96 days[d] = await calculateSlots(server.db, providerId, d, duration)
97 }
98 json(res, { days })
99 } else {
100 const slots = await calculateSlots(server.db, providerId, date, duration)
101 json(res, { date, slots })
102 }
103 })
104}
105
106// ---- Slot Calculation ----
107
108export async function calculateSlots (db, providerId, date, duration) {
109 const availEntry = await db.get(`availability:${providerId}`)
110 if (!availEntry) return []
111 const avail = availEntry.value
112
113 const overridesEntry = await db.get(`availability-overrides:${providerId}`)
114 const overrides = overridesEntry ? overridesEntry.value : []
115
116 // Check for date override
117 const override = overrides.find(o => o.date === date)
118
119 let windows
120 if (override) {
121 if (!override.available) return [] // day off
122 windows = override.windows
123 } else {
124 const dayOfWeek = getDayOfWeek(date)
125 windows = avail.weekly[dayOfWeek]
126 if (!windows || windows.length === 0) return []
127 }
128
129 // Check advance booking limits
130 const now = Date.now()
131 const dateMs = new Date(date + 'T00:00:00Z').getTime()
132 const hoursUntil = (dateMs - now) / 3600000
133
134 if (avail.advanceBooking) {
135 if (hoursUntil < (avail.advanceBooking.minHours || 0)) return []
136 if (hoursUntil / 24 > (avail.advanceBooking.maxDays || 365)) return []
137 }
138
139 // Generate all possible slots from windows
140 const bufferMs = (avail.bufferMinutes || 0) * 60000
141 const durationMs = duration * 60000
142 const allSlots = []
143
144 for (const window of windows) {
145 const startMs = timeToMs(window.start)
146 const endMs = timeToMs(window.end)
147
148 let slotStart = startMs
149 while (slotStart + durationMs <= endMs) {
150 allSlots.push({
151 start: slotStart,
152 end: slotStart + durationMs,
153 startTime: msToTime(slotStart),
154 endTime: msToTime(slotStart + durationMs)
155 })
156 slotStart += durationMs + bufferMs
157 }
158 }
159
160 // Remove slots that conflict with existing bookings
161 const bookings = await getBookingsForDate(db, providerId, date)
162
163 const availableSlots = allSlots.filter(slot => {
164 return !bookings.some(b => {
165 if (b.status === 'cancelled') return false
166 const bStart = timeToMs(b.startTimeLocal) - bufferMs
167 const bEnd = timeToMs(b.endTimeLocal) + bufferMs
168 return slot.start < bEnd && slot.end > bStart
169 })
170 })
171
172 return availableSlots
173}
174
175async function getBookingsForDate (db, providerId, date) {
176 const bookings = []
177 for await (const entry of db.createReadStream({
178 gte: `bookings-by-date:${providerId}:${date}:`,
179 lte: `bookings-by-date:${providerId}:${date}:~`
180 })) {
181 const bookingEntry = await db.get(`booking:${entry.value}`)
182 if (bookingEntry) bookings.push(bookingEntry.value)
183 }
184 return bookings
185}
186
187function getDayOfWeek (dateStr) {
188 const d = new Date(dateStr + 'T12:00:00Z')
189 const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
190 return days[d.getUTCDay()]
191}
192
193function timeToMs (timeStr) {
194 const [h, m] = timeStr.split(':').map(Number)
195 return (h * 60 + m) * 60000
196}
197
198function msToTime (ms) {
199 const totalMin = Math.floor(ms / 60000)
200 const h = Math.floor(totalMin / 60)
201 const m = totalMin % 60
202 return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
203}
204
205function addDays (dateStr, days) {
206 const d = new Date(dateStr + 'T12:00:00Z')
207 d.setUTCDate(d.getUTCDate() + days)
208 return d.toISOString().split('T')[0]
209}
lib/routes/bookings.js +290 -0
……@@ -0,0 +1,290 @@
1/**
2 * Booking routes — create, confirm, cancel, reschedule.
3 */
4
5import { json, readJson, notFound } from '../response.js'
6import { requireAuth, optionalAuth } from './auth-helpers.js'
7import { calculateSlots } from './availability.js'
8
9export function registerBookingRoutes (router, server) {
10 // Create booking (authenticated client)
11 router.post('/api/bookings', async (req, res) => {
12 const auth = await requireAuth(req, server, 'read')
13 const body = await readJson(req)
14
15 const { providerId, packageId, date, startTime, endTime, displayName, email } = body
16
17 if (!providerId || !packageId || !date || !startTime) {
18 json(res, { error: 'providerId, packageId, date, and startTime required' }, 400)
19 return
20 }
21
22 // Get package
23 const pkgEntry = await server.db.get(`package-detail:${packageId}`)
24 if (!pkgEntry) return notFound(res, 'Package not found')
25 const pkg = pkgEntry.value
26
27 // Calculate end time from package duration
28 const slotEnd = endTime || addMinutesToTime(startTime, pkg.sessionDuration)
29
30 // Check slot availability
31 const slots = await calculateSlots(server.db, providerId, date, pkg.sessionDuration)
32 const slotAvailable = slots.some(s => s.startTime === startTime)
33
34 if (!slotAvailable) {
35 json(res, { error: 'Slot not available' }, 409)
36 return
37 }
38
39 // Create or update client record
40 const clientId = await getOrCreateClient(server, auth, displayName, email, providerId)
41
42 // Create booking
43 const booking = {
44 id: server.generateId('bk'),
45 providerId,
46 clientId,
47 packageId,
48 purchaseId: null,
49 date,
50 startTimeLocal: startTime,
51 endTimeLocal: slotEnd,
52 status: pkg.price === 0 ? 'confirmed' : 'pending',
53 rescheduleCount: 0,
54 notes: '',
55 createdAt: Date.now()
56 }
57
58 // If free (discovery call), confirm immediately
59 if (pkg.price === 0) {
60 booking.confirmedAt = Date.now()
61 }
62
63 // Write booking + indexes
64 const batch = server.db.batch()
65 await batch.put(`booking:${booking.id}`, booking)
66 await batch.put(`bookings-by-date:${providerId}:${date}:${booking.id}`, booking.id)
67 await batch.put(`bookings-by-client:${clientId}:${booking.id}`, booking.id)
68 await batch.put(`bookings-by-provider:${providerId}:${booking.id}`, booking.id)
69 await batch.flush()
70
71 json(res, booking, 201)
72 })
73
74 // Get booking detail
75 router.get('/api/bookings/:id', async (req, res) => {
76 const auth = await requireAuth(req, server, 'read')
77 const entry = await server.db.get(`booking:${req.params.id}`)
78 if (!entry) return notFound(res, 'Booking not found')
79 json(res, entry.value)
80 })
81
82 // Cancel booking
83 router.put('/api/bookings/:id/cancel', async (req, res) => {
84 const auth = await requireAuth(req, server, 'read')
85 const entry = await server.db.get(`booking:${req.params.id}`)
86 if (!entry) return notFound(res, 'Booking not found')
87
88 const booking = entry.value
89
90 // Check ownership (client or provider)
91 const isClient = booking.clientId === auth.userId
92 const isProvider = await isProviderOwner(server, booking.providerId, auth.userId)
93
94 if (!isClient && !isProvider && !auth.permissions.global.includes('admin')) {
95 json(res, { error: 'Not authorized' }, 403)
96 return
97 }
98
99 booking.status = 'cancelled'
100 booking.cancelledAt = Date.now()
101 booking.cancelledBy = isProvider ? 'provider' : 'client'
102
103 await server.db.put(`booking:${booking.id}`, booking)
104 json(res, booking)
105 })
106
107 // Reschedule booking
108 router.put('/api/bookings/:id/reschedule', async (req, res) => {
109 const auth = await requireAuth(req, server, 'read')
110 const body = await readJson(req)
111 const { date, startTime } = body
112
113 if (!date || !startTime) {
114 json(res, { error: 'date and startTime required' }, 400)
115 return
116 }
117
118 const entry = await server.db.get(`booking:${req.params.id}`)
119 if (!entry) return notFound(res, 'Booking not found')
120
121 const booking = entry.value
122
123 if (booking.status !== 'confirmed') {
124 json(res, { error: 'Can only reschedule confirmed bookings' }, 400)
125 return
126 }
127
128 // Get package for duration
129 const pkgEntry = await server.db.get(`package-detail:${booking.packageId}`)
130 const duration = pkgEntry ? pkgEntry.value.sessionDuration : 60
131 const endTime = addMinutesToTime(startTime, duration)
132
133 // Check reschedule limit
134 const availEntry = await server.db.get(`availability:${booking.providerId}`)
135 const maxReschedules = availEntry ? (availEntry.value.reschedule?.maxPerBooking || 2) : 2
136 if (booking.rescheduleCount >= maxReschedules) {
137 json(res, { error: 'Maximum reschedules reached' }, 400)
138 return
139 }
140
141 // Check new slot availability
142 const slots = await calculateSlots(server.db, booking.providerId, date, duration)
143 if (!slots.some(s => s.startTime === startTime)) {
144 json(res, { error: 'New slot not available' }, 409)
145 return
146 }
147
148 // Remove old date index
149 const batch = server.db.batch()
150 await batch.del(`bookings-by-date:${booking.providerId}:${booking.date}:${booking.id}`)
151
152 // Update booking
153 const oldDate = booking.date
154 const oldTime = booking.startTimeLocal
155 booking.previousTimes = booking.previousTimes || []
156 booking.previousTimes.push({ date: oldDate, time: oldTime, rescheduledAt: Date.now() })
157 booking.date = date
158 booking.startTimeLocal = startTime
159 booking.endTimeLocal = endTime
160 booking.rescheduleCount++
161 booking.rescheduledAt = Date.now()
162
163 await batch.put(`booking:${booking.id}`, booking)
164 await batch.put(`bookings-by-date:${booking.providerId}:${date}:${booking.id}`, booking.id)
165 await batch.flush()
166
167 json(res, booking)
168 })
169
170 // Mark booking complete (provider)
171 router.put('/api/bookings/:id/complete', async (req, res) => {
172 const auth = await requireAuth(req, server, 'write')
173 const entry = await server.db.get(`booking:${req.params.id}`)
174 if (!entry) return notFound(res, 'Booking not found')
175
176 const booking = entry.value
177 booking.status = 'completed'
178 booking.completedAt = Date.now()
179
180 await server.db.put(`booking:${booking.id}`, booking)
181 json(res, booking)
182 })
183
184 // Add provider notes to booking
185 router.put('/api/bookings/:id/notes', async (req, res) => {
186 const auth = await requireAuth(req, server, 'write')
187 const body = await readJson(req)
188 const entry = await server.db.get(`booking:${req.params.id}`)
189 if (!entry) return notFound(res, 'Booking not found')
190
191 const booking = entry.value
192 booking.notes = body.notes || ''
193
194 await server.db.put(`booking:${booking.id}`, booking)
195 json(res, booking)
196 })
197
198 // List bookings for provider (dashboard)
199 router.get('/api/dashboard/bookings', async (req, res) => {
200 const auth = await requireAuth(req, server, 'write')
201 const url = new URL('http://x' + req.url)
202 const providerId = url.searchParams.get('providerId')
203 const date = url.searchParams.get('date')
204
205 if (!providerId) {
206 json(res, { error: 'providerId required' }, 400)
207 return
208 }
209
210 const bookings = []
211
212 if (date) {
213 // Bookings for specific date
214 for await (const entry of server.db.createReadStream({
215 gte: `bookings-by-date:${providerId}:${date}:`,
216 lte: `bookings-by-date:${providerId}:${date}:~`
217 })) {
218 const bEntry = await server.db.get(`booking:${entry.value}`)
219 if (bEntry) bookings.push(bEntry.value)
220 }
221 } else {
222 // All bookings for provider
223 for await (const entry of server.db.createReadStream({
224 gte: `bookings-by-provider:${providerId}:`,
225 lte: `bookings-by-provider:${providerId}:~`
226 })) {
227 const bEntry = await server.db.get(`booking:${entry.value}`)
228 if (bEntry) bookings.push(bEntry.value)
229 }
230 }
231
232 json(res, bookings)
233 })
234
235 // Client portal — my bookings
236 router.get('/api/portal/bookings', async (req, res) => {
237 const auth = await requireAuth(req, server, 'read')
238 const bookings = []
239
240 for await (const entry of server.db.createReadStream({
241 gte: `bookings-by-client:${auth.userId}:`,
242 lte: `bookings-by-client:${auth.userId}:~`
243 })) {
244 const bEntry = await server.db.get(`booking:${entry.value}`)
245 if (bEntry) bookings.push(bEntry.value)
246 }
247
248 json(res, bookings)
249 })
250}
251
252// ---- Helpers ----
253
254function addMinutesToTime (timeStr, minutes) {
255 const [h, m] = timeStr.split(':').map(Number)
256 const totalMin = h * 60 + m + minutes
257 const newH = Math.floor(totalMin / 60)
258 const newM = totalMin % 60
259 return String(newH).padStart(2, '0') + ':' + String(newM).padStart(2, '0')
260}
261
262async function getOrCreateClient (server, auth, displayName, email, providerId) {
263 const clientKey = `client-by-identity:${auth.identityId}`
264 const existing = await server.db.get(clientKey)
265
266 if (existing) return existing.value
267
268 // Create new client
269 const clientId = auth.userId || server.generateId('cli')
270 const client = {
271 id: clientId,
272 identityId: auth.identityId,
273 displayName: displayName || auth.username || 'Client',
274 email: email || null,
275 createdAt: Date.now()
276 }
277
278 const batch = server.db.batch()
279 await batch.put(`client:${clientId}`, client)
280 await batch.put(clientKey, clientId)
281 await batch.put(`clients-by-provider:${providerId}:${clientId}`, clientId)
282 await batch.flush()
283
284 return clientId
285}
286
287async function isProviderOwner (server, providerId, userId) {
288 const entry = await server.db.get(`provider:${providerId}`)
289 return entry && entry.value.userId === userId
290}
lib/routes/packages.js +149 -0
……@@ -0,0 +1,149 @@
1/**
2 * Package routes — CRUD for service packages.
3 */
4
5import { json, readJson, notFound } from '../response.js'
6import { requireAuth } from './auth-helpers.js'
7
8export function registerPackageRoutes (router, server) {
9 // List packages for a provider (public)
10 router.get('/api/providers/:slug/packages', async (req, res) => {
11 const { slug } = req.params
12 const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
13 if (!providerIdEntry) return notFound(res, 'Provider not found')
14
15 const providerId = providerIdEntry.value
16 const packages = []
17 for await (const entry of server.db.createReadStream({
18 gte: `package:${providerId}:`,
19 lte: `package:${providerId}:~`
20 })) {
21 if (entry.value.active !== false) {
22 packages.push(entry.value)
23 }
24 }
25
26 // Sort: discovery first, then by price
27 packages.sort((a, b) => {
28 if (a.type === 'discovery' && b.type !== 'discovery') return -1
29 if (b.type === 'discovery' && a.type !== 'discovery') return 1
30 return a.price - b.price
31 })
32
33 json(res, packages)
34 })
35
36 // Get single package (public)
37 router.get('/api/packages/:id', async (req, res) => {
38 const entry = await server.db.get(`package-detail:${req.params.id}`)
39 if (!entry) return notFound(res, 'Package not found')
40 json(res, entry.value)
41 })
42
43 // Create package (provider)
44 router.post('/api/packages', async (req, res) => {
45 const auth = await requireAuth(req, server, 'write')
46 const body = await readJson(req)
47
48 const {
49 providerId, name, type, description,
50 sessionDuration, sessionCount, price,
51 currency, discountFullPay, installments,
52 groupCapacity, intakeFormId, contractId
53 } = body
54
55 if (!providerId || !name || !type) {
56 json(res, { error: 'providerId, name, and type required' }, 400)
57 return
58 }
59
60 // Verify provider ownership
61 const providerEntry = await server.db.get(`provider:${providerId}`)
62 if (!providerEntry) return notFound(res, 'Provider not found')
63 if (providerEntry.value.userId !== auth.userId &&
64 !auth.permissions.global.includes('admin')) {
65 json(res, { error: 'Not your provider' }, 403)
66 return
67 }
68
69 const pkg = {
70 id: server.generateId('pkg'),
71 providerId,
72 name,
73 type, // single | bundle | subscription | discovery | group
74 description: description || '',
75 sessionDuration: sessionDuration || 60,
76 sessionCount: sessionCount || 1,
77 price: price || 0, // cents
78 currency: currency || 'USD',
79 discountFullPay: discountFullPay || 0,
80 installments: installments || 0,
81 groupCapacity: groupCapacity || null,
82 intakeFormId: intakeFormId || null,
83 contractId: contractId || null,
84 image: null,
85 active: true,
86 createdAt: Date.now()
87 }
88
89 const batch = server.db.batch()
90 await batch.put(`package:${providerId}:${pkg.id}`, pkg)
91 await batch.put(`package-detail:${pkg.id}`, pkg)
92 await batch.flush()
93
94 json(res, pkg, 201)
95 })
96
97 // Update package (provider)
98 router.put('/api/packages/:id', async (req, res) => {
99 const auth = await requireAuth(req, server, 'write')
100 const entry = await server.db.get(`package-detail:${req.params.id}`)
101 if (!entry) return notFound(res, 'Package not found')
102
103 const pkg = entry.value
104
105 // Verify ownership
106 const providerEntry = await server.db.get(`provider:${pkg.providerId}`)
107 if (providerEntry.value.userId !== auth.userId &&
108 !auth.permissions.global.includes('admin')) {
109 json(res, { error: 'Not your provider' }, 403)
110 return
111 }
112
113 const body = await readJson(req)
114 const updatable = [
115 'name', 'type', 'description', 'sessionDuration', 'sessionCount',
116 'price', 'currency', 'discountFullPay', 'installments',
117 'groupCapacity', 'intakeFormId', 'contractId', 'image', 'active'
118 ]
119 for (const key of updatable) {
120 if (body[key] !== undefined) pkg[key] = body[key]
121 }
122 pkg.updatedAt = Date.now()
123
124 const batch = server.db.batch()
125 await batch.put(`package:${pkg.providerId}:${pkg.id}`, pkg)
126 await batch.put(`package-detail:${pkg.id}`, pkg)
127 await batch.flush()
128
129 json(res, pkg)
130 })
131
132 // Delete (archive) package (provider)
133 router.delete('/api/packages/:id', async (req, res) => {
134 const auth = await requireAuth(req, server, 'write')
135 const entry = await server.db.get(`package-detail:${req.params.id}`)
136 if (!entry) return notFound(res, 'Package not found')
137
138 const pkg = entry.value
139 pkg.active = false
140 pkg.archivedAt = Date.now()
141
142 const batch = server.db.batch()
143 await batch.put(`package:${pkg.providerId}:${pkg.id}`, pkg)
144 await batch.put(`package-detail:${pkg.id}`, pkg)
145 await batch.flush()
146
147 json(res, { ok: true })
148 })
149}
lib/routes/providers.js +106 -0
……@@ -0,0 +1,106 @@
1/**
2 * Provider routes — CRUD for provider profiles.
3 */
4
5import { json, readJson, notFound } from '../response.js'
6import { requireAuth } from './auth-helpers.js'
7
8export function registerProviderRoutes (router, server) {
9 // Get provider by slug (public)
10 router.get('/api/providers/:slug', async (req, res) => {
11 const { slug } = req.params
12 const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
13 if (!providerIdEntry) return notFound(res, 'Provider not found')
14
15 const provider = await server.db.get(`provider:${providerIdEntry.value}`)
16 if (!provider) return notFound(res, 'Provider not found')
17
18 json(res, provider.value)
19 })
20
21 // List all providers (public)
22 router.get('/api/providers', async (req, res) => {
23 const providers = []
24 for await (const entry of server.db.createReadStream({
25 gte: 'provider:',
26 lte: 'provider:~'
27 })) {
28 if (entry.value.active !== false) {
29 providers.push(entry.value)
30 }
31 }
32 json(res, providers)
33 })
34
35 // Create provider (admin or first user)
36 router.post('/api/providers', async (req, res) => {
37 const auth = await requireAuth(req, server, 'admin')
38 const body = await readJson(req)
39
40 const { displayName, slug, bio, timezone, specialties, socialLinks } = body
41
42 if (!displayName || !slug) {
43 json(res, { error: 'displayName and slug required' }, 400)
44 return
45 }
46
47 // Check slug uniqueness
48 const existing = await server.db.get(`provider-by-slug:${slug}`)
49 if (existing) {
50 json(res, { error: 'Slug already taken' }, 409)
51 return
52 }
53
54 const provider = {
55 id: server.generateId('prov'),
56 slug,
57 displayName,
58 bio: bio || '',
59 timezone: timezone || 'America/New_York',
60 specialties: specialties || [],
61 socialLinks: socialLinks || {},
62 photo: null,
63 accentColor: '#c4a265',
64 active: true,
65 userId: auth.userId,
66 createdAt: Date.now()
67 }
68
69 const batch = server.db.batch()
70 await batch.put(`provider:${provider.id}`, provider)
71 await batch.put(`provider-by-slug:${slug}`, provider.id)
72 await batch.flush()
73
74 json(res, provider, 201)
75 })
76
77 // Update provider (owner only)
78 router.put('/api/providers/:slug', async (req, res) => {
79 const auth = await requireAuth(req, server, 'write')
80 const { slug } = req.params
81
82 const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
83 if (!providerIdEntry) return notFound(res, 'Provider not found')
84
85 const entry = await server.db.get(`provider:${providerIdEntry.value}`)
86 if (!entry) return notFound(res, 'Provider not found')
87
88 const provider = entry.value
89
90 // Check ownership
91 if (provider.userId !== auth.userId && !auth.permissions.global.includes('admin')) {
92 json(res, { error: 'Not your provider' }, 403)
93 return
94 }
95
96 const body = await readJson(req)
97 const updatable = ['displayName', 'bio', 'timezone', 'specialties', 'socialLinks', 'photo', 'accentColor']
98 for (const key of updatable) {
99 if (body[key] !== undefined) provider[key] = body[key]
100 }
101 provider.updatedAt = Date.now()
102
103 await server.db.put(`provider:${provider.id}`, provider)
104 json(res, provider)
105 })
106}
lib/routes/web.js +243 -0
……@@ -0,0 +1,243 @@
1/**
2 * Web routes — static files + public provider pages.
3 */
4
5import fs from 'bare-fs'
6import path from 'bare-path'
7import { html, notFound, json } from '../response.js'
8
9const MIME_TYPES = {
10 '.html': 'text/html',
11 '.css': 'text/css',
12 '.js': 'application/javascript',
13 '.json': 'application/json',
14 '.png': 'image/png',
15 '.jpg': 'image/jpeg',
16 '.jpeg': 'image/jpeg',
17 '.svg': 'image/svg+xml',
18 '.ico': 'image/x-icon',
19 '.woff2': 'font/woff2',
20 '.woff': 'font/woff'
21}
22
23export function registerWebRoutes (router, server) {
24 // Root — serve index.html
25 router.get('/', async (req, res) => {
26 serveFile(res, path.join(server.appDir, 'web', 'index.html'))
27 })
28
29 // Static files — /web/*
30 router.get('/web/*filepath', async (req, res) => {
31 const filepath = req.params.filepath
32 // Prevent directory traversal
33 if (filepath.includes('..')) return notFound(res)
34 serveFile(res, path.join(server.appDir, 'web', filepath))
35 })
36
37 // Health check
38 router.get('/api/health', async (req, res) => {
39 json(res, {
40 status: 'ok',
41 version: '0.1.0',
42 uptime: process.uptime ? process.uptime() : 0
43 })
44 })
45}
46
47export function registerPublicRoutes (router, server) {
48 // Booking page — /:slug/book/:packageId
49 router.get('/:slug/book/:packageId', async (req, res) => {
50 const { slug, packageId } = req.params
51
52 const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
53 if (!providerIdEntry) return notFound(res, 'Provider not found')
54
55 const providerEntry = await server.db.get(`provider:${providerIdEntry.value}`)
56 if (!providerEntry) return notFound(res, 'Provider not found')
57
58 const pkgEntry = await server.db.get(`package-detail:${packageId}`)
59 if (!pkgEntry) return notFound(res, 'Package not found')
60
61 const provider = providerEntry.value
62 const pkg = pkgEntry.value
63 const accent = provider.accentColor || '#c4a265'
64 const esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
65
66 const page = `<!DOCTYPE html>
67<html lang="en">
68<head>
69 <meta charset="UTF-8">
70 <meta name="viewport" content="width=device-width, initial-scale=1.0">
71 <title>Book ${esc(pkg.name)} — ${esc(provider.displayName)}</title>
72 <link rel="stylesheet" href="${server.pathPrefix}/web/css/app.css">
73 <style>:root { --accent: ${accent}; }</style>
74</head>
75<body>
76 <div class="container">
77 <nav class="nav-bar">
78 <a href="${server.pathPrefix}/${esc(provider.slug)}" style="text-decoration:none;color:inherit;">&larr; ${esc(provider.displayName)}</a>
79 <button class="btn btn-secondary" id="sign-in-btn">Sign In</button>
80 </nav>
81
82 <section style="padding: 1rem 0;">
83 <h2>${esc(pkg.name)}</h2>
84 <p class="package-meta">${formatPrice(pkg.price)} · ${pkg.sessionDuration} min${pkg.sessionCount > 1 ? ' · ' + pkg.sessionCount + ' sessions' : ''}</p>
85 ${pkg.description ? `<p style="color:var(--text-secondary);margin:1rem 0;">${esc(pkg.description)}</p>` : ''}
86 </section>
87
88 <section>
89 <h2 class="section-title">Select a Time</h2>
90 <div id="booking-container"><p class="empty">Loading slots...</p></div>
91 </section>
92
93 <footer class="footer">
94 <p>Powered by <strong>hyper-booking</strong></p>
95 </footer>
96 </div>
97
98 <script src="${server.pathPrefix}/web/vendor/noble-ed25519.js"></script>
99 <script src="${server.pathPrefix}/web/vendor/noble-sha512.js"></script>
100 <script src="${server.pathPrefix}/web/js/identity.js"></script>
101 <script src="${server.pathPrefix}/web/js/app.js"></script>
102 <script>
103 initBookingPage('${esc(provider.id)}', '${esc(provider.slug)}', '${esc(pkg.id)}')
104 </script>
105</body>
106</html>`
107 html(res, page)
108 })
109
110 // Public provider page — /:slug (MUST be last route)
111 router.get('/:slug', async (req, res) => {
112 const { slug } = req.params
113
114 // Skip API and web paths
115 if (slug.startsWith('api') || slug.startsWith('web') || slug === 'favicon.ico') {
116 return false
117 }
118
119 const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
120 if (!providerIdEntry) return false // fall through to 404
121
122 const providerEntry = await server.db.get(`provider:${providerIdEntry.value}`)
123 if (!providerEntry) return notFound(res, 'Provider not found')
124
125 const provider = providerEntry.value
126
127 // Get packages
128 const packages = []
129 for await (const entry of server.db.createReadStream({
130 gte: `package:${provider.id}:`,
131 lte: `package:${provider.id}:~`
132 })) {
133 if (entry.value.active !== false) {
134 packages.push(entry.value)
135 }
136 }
137
138 packages.sort((a, b) => {
139 if (a.type === 'discovery' && b.type !== 'discovery') return -1
140 if (b.type === 'discovery' && a.type !== 'discovery') return 1
141 return a.price - b.price
142 })
143
144 // Render provider landing page
145 const page = renderProviderPage(provider, packages, server.pathPrefix)
146 html(res, page)
147 })
148}
149
150function serveFile (res, filePath) {
151 try {
152 const content = fs.readFileSync(filePath)
153 const ext = path.extname(filePath).toLowerCase()
154 const contentType = MIME_TYPES[ext] || 'application/octet-stream'
155
156 res.statusCode = 200
157 res.setHeader('Content-Type', contentType)
158 res.setHeader('X-Content-Type-Options', 'nosniff')
159 res.setHeader('Cache-Control', 'public, max-age=3600')
160 res.end(content)
161 } catch {
162 notFound(res)
163 }
164}
165
166function formatPrice (cents) {
167 if (cents === 0) return 'Free'
168 return '$' + (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)
169}
170
171function renderProviderPage (provider, packages, prefix) {
172 const accent = provider.accentColor || '#c4a265'
173 const esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
174
175 const packageCards = packages.map(pkg => {
176 const meta = []
177 if (pkg.price > 0) meta.push(formatPrice(pkg.price))
178 if (pkg.sessionCount > 1) meta.push(`${pkg.sessionCount} sessions`)
179 if (pkg.sessionDuration) meta.push(`${pkg.sessionDuration} min`)
180 if (pkg.discountFullPay) meta.push(`Save ${pkg.discountFullPay}%`)
181
182 const typeLabel = pkg.type === 'discovery' ? ' (Start Here)' : ''
183
184 return `
185 <div class="package-card">
186 <div class="package-info">
187 <h3 class="package-title">${esc(pkg.name)}${typeLabel}</h3>
188 <div class="package-meta">${meta.join(' · ')}</div>
189 <p class="package-desc">${esc(pkg.description)}</p>
190 <a href="${prefix}/${esc(provider.slug)}/book/${esc(pkg.id)}" class="btn btn-accent">View Details →</a>
191 </div>
192 </div>`
193 }).join('\n')
194
195 return `<!DOCTYPE html>
196<html lang="en">
197<head>
198 <meta charset="UTF-8">
199 <meta name="viewport" content="width=device-width, initial-scale=1.0">
200 <title>${esc(provider.displayName)} — hyper-booking</title>
201 <link rel="stylesheet" href="${prefix}/web/css/app.css">
202 <style>:root { --accent: ${accent}; --accent-hover: ${accent}dd; }</style>
203</head>
204<body>
205 <div class="container">
206 <nav class="nav-bar">
207 <span class="nav-logo">hyper-booking</span>
208 <button class="btn btn-secondary" id="sign-in-btn">Sign In</button>
209 </nav>
210
211 <section class="hero">
212 <div class="hero-profile">
213 <div class="avatar">${esc(provider.displayName).charAt(0)}</div>
214 <div>
215 <h1 class="provider-name">${esc(provider.displayName)}</h1>
216 ${provider.specialties && provider.specialties.length
217 ? `<p class="provider-specialties">${provider.specialties.map(esc).join(' · ')}</p>`
218 : ''}
219 </div>
220 </div>
221 </section>
222
223 ${provider.bio ? `
224 <section class="about">
225 <h2>About</h2>
226 <div class="about-text">${esc(provider.bio).replace(/\n/g, '<br>')}</div>
227 </section>` : ''}
228
229 <section class="packages">
230 <h2 class="section-title">Packages</h2>
231 ${packageCards || '<p class="empty">No packages available yet.</p>'}
232 </section>
233
234 <footer class="footer">
235 <p>Powered by <strong>hyper-booking</strong></p>
236 </footer>
237 </div>
238
239 <script src="${prefix}/web/js/identity.js"></script>
240 <script src="${prefix}/web/js/app.js"></script>
241</body>
242</html>`
243}
lib/server.js +145 -0
……@@ -0,0 +1,145 @@
1/**
2 * HyperBookingServer — main server class.
3 *
4 * Follows hypernote pattern:
5 * - Corestore + Hyperbee for data
6 * - AuthManager for identity
7 * - Router for HTTP dispatch
8 * - Static file serving from web/
9 */
10
11import http from 'bare-http1'
12import fs from 'bare-fs'
13import path from 'bare-path'
14import os from 'bare-os'
15import Corestore from 'corestore'
16import Hyperbee from 'hyperbee'
17import crypto from 'bare-crypto'
18
19import { AuthManager } from './auth.js'
20import { Router } from './router.js'
21import { json, notFound, escapeHtml } from './response.js'
22import { registerProviderRoutes } from './routes/providers.js'
23import { registerPackageRoutes } from './routes/packages.js'
24import { registerAvailabilityRoutes } from './routes/availability.js'
25import { registerBookingRoutes } from './routes/bookings.js'
26import { registerAuthRoutes } from './routes/auth.js'
27import { registerWebRoutes, registerPublicRoutes } from './routes/web.js'
28
29export class HyperBookingServer {
30 constructor (options = {}) {
31 this.port = options.port || 4050
32 this.host = options.host || 'localhost'
33 this.pathPrefix = options.pathPrefix || ''
34 this.network = options.network || false
35 this.appDir = path.dirname(new URL(import.meta.url).pathname)
36 this.appDir = path.resolve(this.appDir, '..')
37
38 // Storage
39 this.storage = options.storage || path.join(os.homedir(), '.config', 'hyper-booking', 'data')
40 this.store = new Corestore(this.storage)
41
42 // Registry (Hyperbee for all structured data)
43 this.registryCore = this.store.get({ name: 'registry' })
44 this.db = new Hyperbee(this.registryCore, {
45 keyEncoding: 'utf-8',
46 valueEncoding: 'json'
47 })
48
49 // Auth
50 this.authManager = new AuthManager({
51 registry: this.db,
52 trustedProxies: options.trustedProxies
53 })
54
55 // Router
56 this.router = new Router(this)
57
58 // HTTP server
59 this.httpServer = null
60 }
61
62 async ready () {
63 await this.db.ready()
64 console.log('[hyper-booking] Storage ready:', this.storage)
65
66 // First-run bootstrap
67 const initialized = await this.db.get('meta:initialized')
68 if (!initialized) {
69 await this.db.put('meta:initialized', {
70 at: Date.now(),
71 version: '0.1.0'
72 })
73 console.log('[hyper-booking] First run — admin account will be created on first sign-in')
74 }
75 }
76
77 async start () {
78 this.setupRoutes()
79
80 this.httpServer = http.createServer(async (req, res) => {
81 // CORS
82 const origin = req.headers.origin || ''
83 const allowed = !origin || this.isAllowedOrigin(origin)
84
85 if (allowed && origin) {
86 res.setHeader('Access-Control-Allow-Origin', origin)
87 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
88 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Auth-Token, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge')
89 res.setHeader('Access-Control-Max-Age', '86400')
90 }
91
92 if (req.method === 'OPTIONS') {
93 res.statusCode = 204
94 res.end()
95 return
96 }
97
98 try {
99 const matched = await this.router.dispatch(req, res)
100 if (!matched) notFound(res)
101 } catch (err) {
102 console.error('[Server] Error:', err)
103 if (!res.writableEnded) {
104 res.statusCode = 500
105 res.setHeader('Content-Type', 'application/json')
106 res.end(JSON.stringify({ error: 'Internal server error' }))
107 }
108 }
109 })
110
111 this.httpServer.setTimeout(120000)
112
113 return new Promise((resolve) => {
114 this.httpServer.listen(this.port, this.host, resolve)
115 })
116 }
117
118 setupRoutes () {
119 registerWebRoutes(this.router, this)
120 registerAuthRoutes(this.router, this)
121 registerProviderRoutes(this.router, this)
122 registerPackageRoutes(this.router, this)
123 registerAvailabilityRoutes(this.router, this)
124 registerBookingRoutes(this.router, this)
125 // Public slug routes MUST be last (catch-all /:slug)
126 registerPublicRoutes(this.router, this)
127 }
128
129 isAllowedOrigin (origin) {
130 if (!origin) return true
131 const allowed = ['http://localhost', 'http://127.0.0.1', 'https://localhost']
132 return allowed.some(a => origin.startsWith(a))
133 }
134
135 // ---- Helpers for route handlers ----
136
137 generateId (prefix) {
138 return prefix + '_' + crypto.randomBytes(12).toString('hex')
139 }
140
141 async close () {
142 if (this.httpServer) this.httpServer.close()
143 if (this.store) await this.store.close()
144 }
145}
package.json +38 -0
……@@ -0,0 +1,38 @@
1{
2 "name": "hyper-booking",
3 "version": "0.1.0",
4 "description": "P2P booking platform for wellness professionals",
5 "type": "module",
6 "main": "index.js",
7 "scripts": {
8 "start": "bare index.js serve",
9 "test": "bare node_modules/.bin/brittle test/*.js",
10 "seed": "bare scripts/seed.js"
11 },
12 "dependencies": {
13 "@noble/ed25519": "^2.0.0",
14 "@noble/hashes": "^1.3.3",
15 "b4a": "^1.7.3",
16 "bare-crypto": "^1.13.0",
17 "bare-fs": "^4.5.3",
18 "bare-http1": "^4.2.3",
19 "bare-node-runtime": "^1.1.4",
20 "bare-os": "^3.6.2",
21 "bare-path": "^3.0.0",
22 "corestore": "^7.8.0",
23 "hyperbee": "^2.27.3",
24 "hypercore": "^11.24.0",
25 "hyperswarm": "^4.7.0",
26 "sodium-native": "^4.3.1"
27 },
28 "devDependencies": {
29 "@hyperswarm/testnet": "^3.1.4",
30 "brittle": "^3.7.0",
31 "random-access-memory": "^6.2.1"
32 },
33 "pear": {
34 "stage": {
35 "ignore": ["test/", ".git/", "*.md", "docs/", ".pear-git/"]
36 }
37 }
38}
scripts/seed.js +154 -0
……@@ -0,0 +1,154 @@
1#!/usr/bin/env bare
2
3/**
4 * Seed script — populate dev data.
5 * Usage: bare scripts/seed.js [--storage=path]
6 */
7
8await import('bare-node-runtime/global')
9
10import path from 'bare-path'
11import os from 'bare-os'
12import Corestore from 'corestore'
13import Hyperbee from 'hyperbee'
14import crypto from 'bare-crypto'
15
16const args = process.argv.slice(2)
17let storage = path.join(os.homedir(), '.config', 'hyper-booking', 'data')
18for (const arg of args) {
19 if (arg.startsWith('--storage=')) storage = arg.split('=')[1]
20}
21
22console.log('Seeding into:', storage)
23
24const store = new Corestore(storage)
25const core = store.get({ name: 'registry' })
26const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
27await db.ready()
28
29function genId (prefix) {
30 return prefix + '_' + crypto.randomBytes(12).toString('hex')
31}
32
33// Create test provider
34const providerId = genId('prov')
35const provider = {
36 id: providerId,
37 slug: 'alexis-mattos',
38 displayName: 'Alexis Mattos',
39 bio: 'Holistic health coach specializing in stress management, nutrition, and sustainable wellness. I believe in meeting you where you are and building a personalized plan that fits your life.\n\nWith over 8 years of experience in integrative health coaching, I help clients achieve lasting change through science-backed strategies and compassionate support.',
40 timezone: 'America/New_York',
41 specialties: ['Health Coaching', 'Stress Management', 'Nutrition', 'Wellness'],
42 socialLinks: {},
43 photo: null,
44 accentColor: '#c4a265',
45 active: true,
46 userId: 'admin',
47 createdAt: Date.now()
48}
49
50// Create packages (mirroring the Paperbell screenshots)
51const pkgDiscovery = {
52 id: genId('pkg'),
53 providerId,
54 name: 'Free Discovery Call',
55 type: 'discovery',
56 description: 'A low-pressure conversation to talk through what\'s been on your mind and explore whether coaching could support you right now.',
57 sessionDuration: 15,
58 sessionCount: 1,
59 price: 0,
60 currency: 'USD',
61 active: true,
62 createdAt: Date.now()
63}
64
65const pkgSingle = {
66 id: genId('pkg'),
67 providerId,
68 name: '30-Minute Health Coaching Session',
69 type: 'single',
70 description: 'One more step toward the healthiest version of you.',
71 sessionDuration: 30,
72 sessionCount: 1,
73 price: 7500, // $75
74 currency: 'USD',
75 active: true,
76 createdAt: Date.now()
77}
78
79const pkg60 = {
80 id: genId('pkg'),
81 providerId,
82 name: '60-Minute Deep Dive Session',
83 type: 'single',
84 description: 'A full hour to go deep on your health goals, identify blockers, and create an actionable plan.',
85 sessionDuration: 60,
86 sessionCount: 1,
87 price: 12000, // $120
88 currency: 'USD',
89 active: true,
90 createdAt: Date.now()
91}
92
93const pkgBundle = {
94 id: genId('pkg'),
95 providerId,
96 name: '6-Session Coaching Package',
97 type: 'bundle',
98 description: 'Comprehensive coaching for lasting change. Six 60-minute sessions to work through your goals with accountability and support.',
99 sessionDuration: 60,
100 sessionCount: 6,
101 price: 60000, // $600
102 currency: 'USD',
103 discountFullPay: 10,
104 installments: 3,
105 active: true,
106 createdAt: Date.now()
107}
108
109// Set availability (Mon-Fri, typical coaching schedule)
110const availability = {
111 providerId,
112 timezone: 'America/New_York',
113 weekly: {
114 monday: [{ start: '09:00', end: '12:00' }, { start: '14:00', end: '17:00' }],
115 tuesday: [{ start: '09:00', end: '17:00' }],
116 wednesday: [{ start: '09:00', end: '12:00' }],
117 thursday: [{ start: '10:00', end: '18:00' }],
118 friday: [{ start: '09:00', end: '15:00' }],
119 saturday: [],
120 sunday: []
121 },
122 bufferMinutes: 15,
123 advanceBooking: { minHours: 24, maxDays: 60 },
124 cancellation: { windowHours: 24, refundPolicy: 'full' },
125 reschedule: { windowHours: 24, maxPerBooking: 2 },
126 noShow: { penalty: 'forfeit' }
127}
128
129// Write everything
130const batch = db.batch()
131
132await batch.put(`provider:${providerId}`, provider)
133await batch.put(`provider-by-slug:${provider.slug}`, providerId)
134
135for (const pkg of [pkgDiscovery, pkgSingle, pkg60, pkgBundle]) {
136 await batch.put(`package:${providerId}:${pkg.id}`, pkg)
137 await batch.put(`package-detail:${pkg.id}`, pkg)
138}
139
140await batch.put(`availability:${providerId}`, availability)
141await batch.put('meta:initialized', { at: Date.now(), version: '0.1.0' })
142
143await batch.flush()
144
145console.log('Seeded:')
146console.log(` Provider: ${provider.displayName} (/${provider.slug})`)
147console.log(` Packages: ${[pkgDiscovery, pkgSingle, pkg60, pkgBundle].map(p => p.name).join(', ')}`)
148console.log(` Availability: Mon-Fri`)
149console.log('')
150console.log(`Start server: bare index.js serve`)
151console.log(`Visit: http://localhost:4050/${provider.slug}`)
152
153await store.close()
154process.exit(0)
web/css/app.css +326 -0
……@@ -0,0 +1,326 @@
1/* hyper-booking — Paperbell-inspired minimal design */
2
3:root {
4 --accent: #c4a265;
5 --accent-hover: #b3934f;
6 --bg: #ffffff;
7 --bg-secondary: #f8f6f2;
8 --text: #1a1a1a;
9 --text-secondary: #666666;
10 --border: #e5e5e5;
11 --success: #2d8a4e;
12 --error: #d44;
13 --radius: 8px;
14}
15
16* { box-sizing: border-box; margin: 0; padding: 0; }
17
18body {
19 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
20 font-size: 16px;
21 line-height: 1.6;
22 color: var(--text);
23 background: var(--bg);
24}
25
26.container {
27 max-width: 800px;
28 margin: 0 auto;
29 padding: 0 1.5rem;
30}
31
32/* Nav */
33.nav-bar {
34 display: flex;
35 justify-content: space-between;
36 align-items: center;
37 padding: 1rem 0;
38 border-bottom: 1px solid var(--border);
39 margin-bottom: 2rem;
40}
41
42.nav-logo {
43 font-weight: 600;
44 font-size: 1.1rem;
45 color: var(--text);
46}
47
48/* Buttons */
49.btn {
50 display: inline-block;
51 padding: 0.6rem 1.2rem;
52 border-radius: 20px;
53 font-size: 0.9rem;
54 cursor: pointer;
55 text-decoration: none;
56 border: none;
57 font-family: inherit;
58 transition: all 0.15s;
59}
60
61.btn-accent {
62 background: var(--accent);
63 color: white;
64}
65.btn-accent:hover { background: var(--accent-hover); }
66
67.btn-secondary {
68 background: white;
69 color: var(--text);
70 border: 1px solid var(--border);
71}
72.btn-secondary:hover { border-color: var(--accent); }
73
74.btn-primary {
75 background: var(--accent);
76 color: white;
77 width: 100%;
78 padding: 1rem;
79 font-size: 1.1rem;
80 border-radius: var(--radius);
81}
82
83.btn-danger {
84 background: var(--error);
85 color: white;
86}
87
88/* Hero */
89.hero {
90 padding: 2rem 0;
91}
92
93.hero-profile {
94 display: flex;
95 align-items: center;
96 gap: 1.5rem;
97}
98
99.avatar {
100 width: 80px;
101 height: 80px;
102 border-radius: 50%;
103 background: var(--accent);
104 color: white;
105 display: flex;
106 align-items: center;
107 justify-content: center;
108 font-size: 2rem;
109 font-weight: 600;
110 flex-shrink: 0;
111}
112
113.provider-name {
114 font-size: 2rem;
115 font-weight: 600;
116 margin-bottom: 0.25rem;
117}
118
119.provider-specialties {
120 color: var(--text-secondary);
121 font-size: 0.95rem;
122}
123
124/* About */
125.about {
126 padding: 2rem 0;
127 border-top: 1px solid var(--border);
128}
129
130.about h2 {
131 font-size: 1.5rem;
132 margin-bottom: 1rem;
133 color: var(--accent);
134}
135
136.about-text {
137 color: var(--text-secondary);
138 line-height: 1.8;
139}
140
141/* Packages */
142.packages {
143 padding: 2rem 0;
144 border-top: 1px solid var(--border);
145}
146
147.section-title {
148 font-size: 1.5rem;
149 text-align: center;
150 margin-bottom: 2rem;
151}
152
153.package-card {
154 display: flex;
155 justify-content: space-between;
156 align-items: center;
157 padding: 1.5rem;
158 border: 1px solid var(--border);
159 border-radius: var(--radius);
160 margin-bottom: 1rem;
161 transition: border-color 0.15s;
162}
163.package-card:hover { border-color: var(--accent); }
164
165.package-info { flex: 1; }
166
167.package-title {
168 font-size: 1.2rem;
169 font-weight: 600;
170 margin-bottom: 0.25rem;
171}
172
173.package-meta {
174 color: var(--text-secondary);
175 font-size: 0.9rem;
176 margin-bottom: 0.5rem;
177}
178
179.package-desc {
180 color: var(--text-secondary);
181 font-size: 0.95rem;
182 margin-bottom: 1rem;
183}
184
185/* Calendar */
186.calendar-nav {
187 display: flex;
188 justify-content: space-between;
189 align-items: center;
190 margin-bottom: 1.5rem;
191}
192
193.calendar-nav button {
194 background: none;
195 border: 1px solid var(--border);
196 border-radius: var(--radius);
197 padding: 0.5rem 1rem;
198 cursor: pointer;
199 font-size: 1rem;
200}
201
202.calendar-grid {
203 display: grid;
204 grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
205 gap: 1rem;
206 margin-bottom: 2rem;
207}
208
209.day-column h4 {
210 font-size: 0.85rem;
211 color: var(--text-secondary);
212 text-align: center;
213 margin-bottom: 0.5rem;
214 font-weight: 500;
215}
216
217.slot-btn {
218 width: 100%;
219 padding: 0.6rem;
220 margin-bottom: 0.4rem;
221 border: 1px solid var(--border);
222 border-radius: 6px;
223 background: white;
224 cursor: pointer;
225 text-align: center;
226 font-size: 0.9rem;
227 font-family: inherit;
228 transition: all 0.15s;
229}
230.slot-btn:hover { border-color: var(--accent); background: #faf8f4; }
231.slot-btn.selected { background: var(--accent); color: white; border-color: var(--accent); }
232
233.no-slots {
234 text-align: center;
235 color: var(--text-secondary);
236 font-size: 0.85rem;
237 padding: 1rem 0;
238}
239
240/* Cards */
241.card {
242 padding: 1.5rem;
243 border: 1px solid var(--border);
244 border-radius: var(--radius);
245 background: white;
246 margin-bottom: 1rem;
247}
248
249/* Forms */
250input, select, textarea {
251 width: 100%;
252 padding: 0.75rem;
253 border: 1px solid var(--border);
254 border-radius: 6px;
255 font-size: 1rem;
256 font-family: inherit;
257 margin-bottom: 1rem;
258}
259input:focus, select:focus, textarea:focus {
260 border-color: var(--accent);
261 outline: none;
262 box-shadow: 0 0 0 2px rgba(196, 162, 101, 0.2);
263}
264
265label {
266 display: block;
267 font-size: 0.875rem;
268 font-weight: 500;
269 margin-bottom: 0.25rem;
270}
271
272/* Toast */
273.toast {
274 position: fixed;
275 bottom: 2rem;
276 right: 2rem;
277 padding: 1rem 1.5rem;
278 background: var(--text);
279 color: white;
280 border-radius: var(--radius);
281 font-size: 0.9rem;
282 z-index: 1000;
283 animation: slideIn 0.3s ease;
284}
285.toast.error { background: var(--error); }
286.toast.success { background: var(--success); }
287
288@keyframes slideIn {
289 from { transform: translateY(1rem); opacity: 0; }
290 to { transform: translateY(0); opacity: 1; }
291}
292
293/* Footer */
294.footer {
295 padding: 2rem 0;
296 text-align: center;
297 color: var(--text-secondary);
298 font-size: 0.85rem;
299 border-top: 1px solid var(--border);
300 margin-top: 2rem;
301}
302
303/* Empty state */
304.empty {
305 text-align: center;
306 color: var(--text-secondary);
307 padding: 2rem;
308}
309
310/* Selected slot summary */
311.selected-slot {
312 background: var(--bg-secondary);
313 padding: 1rem 1.5rem;
314 border-radius: var(--radius);
315 margin: 1rem 0;
316 text-align: center;
317}
318
319/* Responsive */
320@media (max-width: 640px) {
321 .container { padding: 0 1rem; }
322 .hero-profile { flex-direction: column; text-align: center; }
323 .provider-name { font-size: 1.5rem; }
324 .calendar-grid { grid-template-columns: 1fr; }
325 .package-card { flex-direction: column; }
326}
web/js/app.js +185 -0
……@@ -0,0 +1,185 @@
1/**
2 * hyper-booking — client-side SPA logic.
3 * Minimal vanilla JS, no framework.
4 */
5
6/* global HyperBookingIdentity */
7
8// ---- Utilities ----
9
10function showToast (message, type = 'info') {
11 const toast = document.createElement('div')
12 toast.className = 'toast' + (type !== 'info' ? ' ' + type : '')
13 toast.textContent = message
14 document.body.appendChild(toast)
15 setTimeout(() => toast.remove(), 3500)
16}
17
18async function api (method, path, body) {
19 const opts = {
20 method,
21 headers: { 'Content-Type': 'application/json' }
22 }
23 if (body) opts.body = JSON.stringify(body)
24
25 const res = await fetch(path, opts)
26 const data = await res.json()
27 if (!res.ok) throw new Error(data.error || 'Request failed')
28 return data
29}
30
31function formatTime (time24) {
32 const [h, m] = time24.split(':').map(Number)
33 const ampm = h >= 12 ? 'PM' : 'AM'
34 const h12 = h % 12 || 12
35 return `${h12}:${String(m).padStart(2, '0')} ${ampm}`
36}
37
38function formatDate (dateStr) {
39 const d = new Date(dateStr + 'T12:00:00Z')
40 return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
41}
42
43function formatPrice (cents) {
44 if (cents === 0) return 'Free'
45 return '$' + (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)
46}
47
48// ---- Sign In ----
49
50const signInBtn = document.getElementById('sign-in-btn')
51if (signInBtn) {
52 signInBtn.addEventListener('click', async () => {
53 if (typeof HyperBookingIdentity !== 'undefined' && HyperBookingIdentity.hasKeypair()) {
54 showToast('Already signed in')
55 return
56 }
57 if (typeof HyperBookingIdentity !== 'undefined') {
58 await HyperBookingIdentity.generate()
59 showToast('Identity created', 'success')
60 signInBtn.textContent = 'Signed In'
61 }
62 })
63
64 // Update button if already signed in
65 if (typeof HyperBookingIdentity !== 'undefined' && HyperBookingIdentity.hasKeypair()) {
66 signInBtn.textContent = 'Signed In'
67 }
68}
69
70// ---- Booking Page ----
71// When a booking page loads, this handles the calendar + slot selection
72
73async function initBookingPage (providerId, providerSlug, packageId) {
74 const pkg = await api('GET', `/api/packages/${packageId}`)
75 const today = new Date()
76 let weekStart = new Date(today)
77 weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1) // Monday
78
79 const container = document.getElementById('booking-container')
80 if (!container) return
81
82 let selectedSlot = null
83
84 async function loadWeek () {
85 const weekStr = weekStart.toISOString().split('T')[0]
86 const data = await api('GET', `/api/providers/${providerSlug}/slots?weekOf=${weekStr}&duration=${pkg.sessionDuration}`)
87
88 const weekEnd = new Date(weekStart)
89 weekEnd.setDate(weekEnd.getDate() + 6)
90
91 let html = `
92 <div class="calendar-nav">
93 <button id="prev-week">&larr;</button>
94 <span>${formatDate(weekStr)} &ndash; ${formatDate(weekEnd.toISOString().split('T')[0])}</span>
95 <button id="next-week">&rarr;</button>
96 </div>
97 <div class="calendar-grid">
98 `
99
100 for (const [date, slots] of Object.entries(data.days)) {
101 html += `<div class="day-column"><h4>${formatDate(date)}</h4>`
102 if (slots.length === 0) {
103 html += '<p class="no-slots">No availability</p>'
104 } else {
105 for (const slot of slots) {
106 html += `<button class="slot-btn" data-date="${date}" data-time="${slot.startTime}">${formatTime(slot.startTime)}</button>`
107 }
108 }
109 html += '</div>'
110 }
111
112 html += '</div>'
113 html += '<div id="selected-summary"></div>'
114 html += '<button class="btn btn-primary" id="confirm-booking" disabled>Select a time to continue</button>'
115
116 container.innerHTML = html
117
118 // Event listeners
119 document.getElementById('prev-week').addEventListener('click', () => {
120 weekStart.setDate(weekStart.getDate() - 7)
121 loadWeek()
122 })
123 document.getElementById('next-week').addEventListener('click', () => {
124 weekStart.setDate(weekStart.getDate() + 7)
125 loadWeek()
126 })
127
128 container.querySelectorAll('.slot-btn').forEach(btn => {
129 btn.addEventListener('click', () => {
130 container.querySelectorAll('.slot-btn').forEach(b => b.classList.remove('selected'))
131 btn.classList.add('selected')
132 selectedSlot = { date: btn.dataset.date, time: btn.dataset.time }
133
134 document.getElementById('selected-summary').innerHTML = `
135 <div class="selected-slot">
136 <strong>${formatDate(selectedSlot.date)}</strong> at <strong>${formatTime(selectedSlot.time)}</strong>
137 </div>
138 `
139 const confirmBtn = document.getElementById('confirm-booking')
140 confirmBtn.disabled = false
141 confirmBtn.textContent = pkg.price === 0 ? 'Book Free Session' : `Book — ${formatPrice(pkg.price)}`
142 })
143 })
144
145 document.getElementById('confirm-booking').addEventListener('click', async () => {
146 if (!selectedSlot) return
147
148 // Ensure identity exists
149 if (typeof HyperBookingIdentity !== 'undefined' && !HyperBookingIdentity.hasKeypair()) {
150 await HyperBookingIdentity.generate()
151 }
152
153 try {
154 const booking = await api('POST', '/api/bookings', {
155 providerId,
156 packageId,
157 date: selectedSlot.date,
158 startTime: selectedSlot.time
159 })
160
161 container.innerHTML = `
162 <div style="text-align: center; padding: 3rem 0;">
163 <h2 style="color: var(--success);">Booking Confirmed!</h2>
164 <p style="margin: 1rem 0;">
165 <strong>${formatDate(booking.date)}</strong> at <strong>${formatTime(booking.startTimeLocal)}</strong>
166 </p>
167 <p style="color: var(--text-secondary);">${pkg.name}</p>
168 <p style="margin-top: 2rem;">
169 <a href="/${providerSlug}" class="btn btn-secondary">Back to Provider</a>
170 </p>
171 </div>
172 `
173 showToast('Booking confirmed!', 'success')
174 } catch (err) {
175 showToast(err.message, 'error')
176 }
177 })
178 }
179
180 await loadWeek()
181}
182
183// Expose for inline use
184window.initBookingPage = initBookingPage
185window.showToast = showToast
web/js/identity.js +296 -0
……@@ -0,0 +1,296 @@
1/**
2 * Hypernote Identity — Browser ed25519 auth
3 *
4 * Generates ed25519 keypair CLIENT-SIDE using @noble/ed25519.
5 * The secret key never leaves the browser.
6 * Uses challenge-response signing for every authenticated request.
7 * Intercepts fetch() to inject auth headers automatically.
8 */
9
10window.HnIdentity = (function () {
11 var KEYPAIR_KEY = 'hn-keypair'
12 var PUBKEY_KEY = 'hn-pubkey'
13 var USERNAME_KEY = 'hn-username'
14
15 // Challenge cache — reuse until 1 min before expiry
16 var cachedChallenge = null
17
18 // @noble/ed25519 loaded via CDN
19 var ed = null
20 var sha512 = null
21 var nobleLoading = null
22
23 /**
24 * Load @noble/ed25519 from CDN
25 */
26 function loadNoble () {
27 if (ed) return Promise.resolve()
28 if (nobleLoading) return nobleLoading
29
30 nobleLoading = (async function () {
31 try {
32 var module = await import('https://esm.sh/@noble/ed25519@2.0.0')
33 ed = module
34 var hashModule = await import('https://esm.sh/@noble/hashes@1.3.3/sha512')
35 sha512 = hashModule.sha512
36 ed.etc.sha512Sync = function () {
37 return sha512.apply(null, arguments)
38 }
39 } catch (err) {
40 nobleLoading = null
41 console.error('[Identity] Failed to load noble ed25519:', err)
42 throw err
43 }
44 })()
45
46 return nobleLoading
47 }
48
49 /**
50 * Get stored keypair or null
51 */
52 function getKeypair () {
53 return localStorage.getItem(KEYPAIR_KEY) || null
54 }
55
56 /**
57 * Get stored public key
58 */
59 function getPublicKey () {
60 return localStorage.getItem(PUBKEY_KEY) || null
61 }
62
63 /**
64 * Get stored username
65 */
66 function getUsername () {
67 return localStorage.getItem(USERNAME_KEY) || null
68 }
69
70 /**
71 * Set stored username
72 */
73 function setUsername (u) {
74 if (u) {
75 localStorage.setItem(USERNAME_KEY, u)
76 } else {
77 localStorage.removeItem(USERNAME_KEY)
78 }
79 }
80
81 /**
82 * Generate new ed25519 keypair CLIENT-SIDE
83 * Secret key never leaves the browser.
84 */
85 async function generate () {
86 await loadNoble()
87
88 // Generate 32-byte random seed using browser crypto
89 var seed = new Uint8Array(32)
90 crypto.getRandomValues(seed)
91
92 // Derive ed25519 public key from seed
93 var publicKeyBytes = await ed.getPublicKeyAsync(seed)
94
95 // Build 64-byte secret key (seed + public key, matching hypercore-crypto format)
96 var secretKeyBytes = new Uint8Array(64)
97 secretKeyBytes.set(seed, 0)
98 secretKeyBytes.set(publicKeyBytes, 32)
99
100 var publicKeyB64 = bytesToBase64(publicKeyBytes) // 44 chars
101 var secretKeyB64 = bytesToBase64(secretKeyBytes) // 88 chars
102 var keypair = publicKeyB64 + secretKeyB64 // 132 chars
103
104 localStorage.setItem(KEYPAIR_KEY, keypair)
105 localStorage.setItem(PUBKEY_KEY, publicKeyB64)
106
107 return { publicKey: publicKeyB64, keypair: keypair }
108 }
109
110 /**
111 * Get a fresh or cached challenge
112 */
113 async function getChallenge () {
114 if (cachedChallenge) {
115 var remaining = cachedChallenge.expiresAt - Date.now()
116 if (remaining > 60000) return cachedChallenge
117 }
118
119 var base = (window.HnApi && window.HnApi.basePath) || ''
120 var res = await originalFetch(base + '/api/auth/challenge', {
121 method: 'POST',
122 headers: { 'Content-Type': 'application/json' }
123 })
124 if (!res.ok) throw new Error('Failed to get challenge')
125 cachedChallenge = await res.json()
126 return cachedChallenge
127 }
128
129 /**
130 * Sign a challenge string with the stored keypair
131 */
132 async function signChallenge (challenge) {
133 await loadNoble()
134 var keypair = getKeypair()
135 if (!keypair) throw new Error('No keypair stored')
136
137 var secretKeyB64 = keypair.substring(44, 132)
138 var secretKey = base64ToBytes(secretKeyB64)
139
140 // noble expects the 32-byte seed (first 32 bytes of 64-byte secret key)
141 var seed = secretKey.slice(0, 32)
142 var challengeBytes = new TextEncoder().encode(challenge)
143 var signature = ed.sign(challengeBytes, seed)
144
145 return bytesToBase64(signature)
146 }
147
148 function base64ToBytes (b64) {
149 var bin = atob(b64)
150 var bytes = new Uint8Array(bin.length)
151 for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
152 return bytes
153 }
154
155 function bytesToBase64 (bytes) {
156 var bin = ''
157 for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
158 return btoa(bin)
159 }
160
161 /**
162 * Clear identity (logout)
163 */
164 function clear () {
165 localStorage.removeItem(KEYPAIR_KEY)
166 localStorage.removeItem(PUBKEY_KEY)
167 localStorage.removeItem(USERNAME_KEY)
168 localStorage.removeItem('hn-token')
169 cachedChallenge = null
170 }
171
172 /**
173 * Import keypair from 132-char string
174 */
175 function importKeypair (keypairStr) {
176 if (!keypairStr || keypairStr.length !== 132) {
177 throw new Error('Invalid keypair (must be 132 chars)')
178 }
179 var pubKey = keypairStr.substring(0, 44)
180 localStorage.setItem(KEYPAIR_KEY, keypairStr)
181 localStorage.setItem(PUBKEY_KEY, pubKey)
182 cachedChallenge = null
183 return pubKey
184 }
185
186 /**
187 * Export keypair as JSON object (for saving to file or copying)
188 */
189 function exportKeypair () {
190 var keypair = getKeypair()
191 var pubKey = getPublicKey()
192 var username = getUsername()
193 if (!keypair) return null
194 return {
195 publicKey: pubKey,
196 keypair: keypair,
197 username: username,
198 exportedAt: new Date().toISOString()
199 }
200 }
201
202 // ==================== FETCH INTERCEPTION ====================
203
204 /**
205 * Skip auth when user has no identity (anonymous browsing — reads are open).
206 * When user HAS an identity, always send auth headers (even for GETs like profile).
207 */
208 function shouldSkipAuth (url, method) {
209 if (url.indexOf('/api/') === -1) return true
210 // Challenge endpoint must be unauthenticated
211 if (url.indexOf('/api/auth/challenge') !== -1) return true
212 // Username availability check is public
213 if (url.indexOf('/available') !== -1) return true
214 // If no identity, skip auth (anonymous browsing — reads are open)
215 if (!getPublicKey()) return true
216 return false
217 }
218
219 var originalFetch = window.fetch
220
221 async function authenticatedFetch (input, init) {
222 var url = typeof input === 'string' ? input : input.url || ''
223 var method = (init && init.method) ? init.method.toUpperCase() : 'GET'
224
225 if (shouldSkipAuth(url, method)) {
226 return originalFetch(input, init)
227 }
228
229 var pubKey = getPublicKey()
230 if (!pubKey) {
231 return originalFetch(input, init)
232 }
233
234 // Legacy bearer token support
235 var legacyToken = localStorage.getItem('hn-token')
236 if (legacyToken) {
237 init = init || {}
238 init.headers = init.headers || {}
239 init.headers['Authorization'] = 'Bearer ' + legacyToken
240 return originalFetch(input, init)
241 }
242
243 try {
244 var ch = await getChallenge()
245 var signature = await signChallenge(ch.challenge)
246
247 init = init || {}
248 init.headers = init.headers || {}
249 init.headers['X-Wirecore-PublicKey'] = pubKey
250 init.headers['X-Wirecore-Signature'] = signature
251 init.headers['X-Wirecore-Challenge'] = ch.challengeId
252
253 cachedChallenge = null
254
255 var res = await originalFetch(input, init)
256
257 // On 401, retry once with fresh challenge
258 if (res.status === 401) {
259 cachedChallenge = null
260 ch = await getChallenge()
261 signature = await signChallenge(ch.challenge)
262
263 init.headers['X-Wirecore-PublicKey'] = pubKey
264 init.headers['X-Wirecore-Signature'] = signature
265 init.headers['X-Wirecore-Challenge'] = ch.challengeId
266 cachedChallenge = null
267
268 res = await originalFetch(input, init)
269 }
270
271 return res
272 } catch (err) {
273 console.error('[Identity] Auth interception failed:', err)
274 return originalFetch(input, init)
275 }
276 }
277
278 window.fetch = authenticatedFetch
279
280 // ==================== PUBLIC API ====================
281
282 return {
283 getKeypair: getKeypair,
284 getPublicKey: getPublicKey,
285 getUsername: getUsername,
286 setUsername: setUsername,
287 generate: generate,
288 getChallenge: getChallenge,
289 signChallenge: signChallenge,
290 hasIdentity: function () { return !!getKeypair() },
291 loadNoble: loadNoble,
292 clear: clear,
293 importKeypair: importKeypair,
294 exportKeypair: exportKeypair
295 }
296})()
web/vendor/marked.min.js +6 -0
……@@ -0,0 +1,6 @@
1/**
2 * marked v14.0.0 - a markdown parser
3 * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
4 * https://github.com/markedjs/marked
5 */
6!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/(^|[^\[])\^/g;function p(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(h,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function u(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch{return null}return e}const k={exec:()=>null};function g(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length<t;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(/\\\|/g,"|");return n}function f(e,t,n){const s=e.length;if(0===s)return"";let r=0;for(;r<s;){const i=e.charAt(s-r-1);if(i!==t||n){if(i===t||!n)break;r++}else r++}return e.slice(0,s-r)}function d(e,t,n,s){const r=t.href,i=t.title?c(t.title):null,l=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){s.state.inLink=!0;const e={type:"link",raw:n,href:r,title:i,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,e}return{type:"image",raw:n,href:r,title:i,text:c(l)}}class x{options;rules;lexer;constructor(t){this.options=t||e.defaults}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:f(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=f(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:f(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=f(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l<e.length;l++)if(/^ {0,3}>/.test(e[l]))i.push(e[l]),t=!0;else{if(t)break;i.push(e[l])}e=e.slice(l);const o=i.join("\n"),a=o.replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1").replace(/^ {0,3}>[ \t]?/gm,"");n=n?`${n}\n${o}`:o,s=s?`${s}\n${a}`:a;const c=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(a,r,!0),this.lexer.state.top=c,0===e.length)break;const h=r[r.length-1];if("code"===h?.type)break;if("blockquote"===h?.type){const t=h,i=t.raw+"\n"+e.join("\n"),l=this.blockquote(i);r[r.length-1]=l,n=n.substring(0,n.length-t.raw.length)+l.raw,s=s.substring(0,s.length-t.text.length)+l.text;break}if("list"!==h?.type);else{const t=h,i=t.raw+"\n"+e.join("\n"),l=this.list(i);r[r.length-1]=l,n=n.substring(0,n.length-h.raw.length)+l.raw,s=s.substring(0,s.length-t.raw.length)+l.raw,e=i.substring(r[r.length-1].raw.length).split("\n")}}return{type:"blockquote",raw:n,tokens:r,text:s}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(/[^ ]/),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&/^ *$/.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,p-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,p-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,p-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,p-1)}}#`);for(;e;){const l=e.split("\n",1)[0];if(c=l,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=p||!c.trim())o+="\n"+c.slice(p);else{if(h)break;if(a.search(/[^ ]/)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=l+"\n",e=e.substring(l.length+1),a=c.slice(p)}}r.loose||(l?r.loose=!0:/\n *\n *$/.test(s)&&(l=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:s,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}r.items[r.items.length-1].raw=r.items[r.items.length-1].raw.trimEnd(),r.items[r.items.length-1].text=r.items[r.items.length-1].text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e<r.items.length;e++)if(this.lexer.state.top=!1,r.items[e].tokens=this.lexer.blockTokens(r.items[e].text,[]),!r.loose){const t=r.items[e].tokens.filter((e=>"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e<r.items.length;e++)r.items[e].loose=!0;return r}}html(e){const t=this.rules.block.html.exec(e);if(t){return{type:"html",block:!0,raw:t[0],pre:"pre"===t[1]||"script"===t[1]||"style"===t[1],text:t[0]}}}def(e){const t=this.rules.block.def.exec(e);if(t){const e=t[1].toLowerCase().replace(/\s+/g," "),n=t[2]?t[2].replace(/^<(.*)>$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=g(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(let e=0;e<n.length;e++)i.header.push({text:n[e],tokens:this.lexer.inline(n[e]),header:!0,align:i.align[e]});for(const e of r)i.rows.push(g(e,i.header.length).map(((e,t)=>({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^<a /i.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&/^<\/a>/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^</.test(e)){if(!/>$/.test(e))return;const t=f(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s<e.length;s++)if("\\"===e[s])s++;else if(e[s]===t[0])n++;else if(e[s]===t[1]&&(n--,n<0))return s;return-1}(t[2],"()");if(e>-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^</.test(n)&&(n=this.options.pedantic&&!/>$/.test(e)?n.slice(1):n.slice(1,-1)),d(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return d(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const b=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,w=/(?:[*+-]|\d{1,9}[.)])/,m=p(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,w).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),y=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,$=/(?!\s*\])(?:\\.|[^\[\]\\])+/,z=p(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",$).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),T=p(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,w).getRegex(),R="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",_=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,A=p("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",_).replace("tag",R).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),S=p(y).replace("hr",b).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",R).getRegex(),I={blockquote:p(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",S).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:z,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:b,html:A,lheading:m,list:T,newline:/^(?: *(?:\n|$))+/,paragraph:S,table:k,text:/^[^\n]+/},E=p("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",b).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",R).getRegex(),q={...I,table:E,paragraph:p(y).replace("hr",b).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",E).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",R).getRegex()},Z={...I,html:p("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)|<tag(?:\"[^\"]*\"|'[^']*'|\\s[^'\"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",_).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:k,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:p(y).replace("hr",b).replace("heading"," *#{1,6} *[^\n]").replace("lheading",m).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},P=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,L=/^( {2,}|\\)\n(?!\s*$)/,Q="\\p{P}\\p{S}",v=p(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,Q).getRegex(),B=p(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,Q).getRegex(),M=p("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,Q).getRegex(),O=p("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,Q).getRegex(),j=p(/\\([punct])/,"gu").replace(/punct/g,Q).getRegex(),D=p(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),C=p(_).replace("(?:--\x3e|$)","--\x3e").getRegex(),H=p("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",C).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),U=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,X=p(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",U).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),F=p(/^!?\[(label)\]\[(ref)\]/).replace("label",U).replace("ref",$).getRegex(),N=p(/^!?\[(ref)\](?:\[\])?/).replace("ref",$).getRegex(),G={_backpedal:k,anyPunctuation:j,autolink:D,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:L,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:k,emStrongLDelim:B,emStrongRDelimAst:M,emStrongRDelimUnd:O,escape:P,link:X,nolink:N,punctuation:v,reflink:F,reflinkSearch:p("reflink|nolink(?!\\()","g").replace("reflink",F).replace("nolink",N).getRegex(),tag:H,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,url:k},J={...G,link:p(/^!?\[(label)\]\((.*?)\)/).replace("label",U).getRegex(),reflink:p(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",U).getRegex()},K={...G,escape:p(P).replace("])","~|])").getRegex(),url:p(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},V={...K,br:p(L).replace("{2,}","*").getRegex(),text:p(K.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},W={normal:I,gfm:q,pedantic:Z},Y={normal:G,gfm:K,breaks:V,pedantic:J};class ee{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new x,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={block:W.normal,inline:Y.normal};this.options.pedantic?(n.block=W.pedantic,n.inline=Y.pedantic):this.options.gfm&&(n.block=W.gfm,this.options.breaks?n.inline=Y.breaks:n.inline=Y.gfm),this.tokenizer.rules=n}static get rules(){return{block:W,inline:Y}}static lex(e,t){return new ee(t).lex(e)}static lexInline(e,t){return new ee(t).inlineTokens(e)}lex(e){e=e.replace(/\r\n|\r/g,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e<this.inlineQueue.length;e++){const t=this.inlineQueue[e];this.inlineTokens(t.src,t.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){let s,r,i;for(e=this.options.pedantic?e.replace(/\t/g," ").replace(/^ +$/gm,""):e.replace(/^( *)(\t+)/gm,((e,t,n)=>t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0)))))if(s=this.tokenizer.space(e))e=e.substring(s.raw.length),1===s.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(s);else if(s=this.tokenizer.code(e))e=e.substring(s.raw.length),r=t[t.length-1],!r||"paragraph"!==r.type&&"text"!==r.type?t.push(s):(r.raw+="\n"+s.raw,r.text+="\n"+s.text,this.inlineQueue[this.inlineQueue.length-1].src=r.text);else if(s=this.tokenizer.fences(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.heading(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.hr(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.blockquote(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.list(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.html(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.def(e))e=e.substring(s.raw.length),r=t[t.length-1],!r||"paragraph"!==r.type&&"text"!==r.type?this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title}):(r.raw+="\n"+s.raw,r.text+="\n"+s.raw,this.inlineQueue[this.inlineQueue.length-1].src=r.text);else if(s=this.tokenizer.table(e))e=e.substring(s.raw.length),t.push(s);else if(s=this.tokenizer.lheading(e))e=e.substring(s.raw.length),t.push(s);else{if(i=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(i=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i)))r=t[t.length-1],n&&"paragraph"===r?.type?(r.raw+="\n"+s.raw,r.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);else if(s=this.tokenizer.text(e))e=e.substring(s.raw.length),r=t[t.length-1],r&&"text"===r.type?(r.raw+="\n"+s.raw,r.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=r.text):t.push(s);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class te{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(/^\S*/)?.[0],r=e.replace(/\n$/,"")+"\n";return s?'<pre><code class="language-'+c(s)+'">'+(n?r:c(r,!0))+"</code></pre>\n":"<pre><code>"+(n?r:c(r,!0))+"</code></pre>\n"}blockquote({tokens:e}){return`<blockquote>\n${this.parser.parse(e)}</blockquote>\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`<h${t}>${this.parser.parseInline(e)}</h${t}>\n`}hr(e){return"<hr>\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t<e.items.length;t++){const n=e.items[t];s+=this.listitem(n)}const r=t?"ol":"ul";return"<"+r+(t&&1!==n?' start="'+n+'"':"")+">\n"+s+"</"+r+">\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?e.tokens.length>0&&"paragraph"===e.tokens[0].type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+e.tokens[0].tokens[0].text)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" "}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`<li>${t}</li>\n`}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph({tokens:e}){return`<p>${this.parser.parseInline(e)}</p>\n`}table(e){let t="",n="";for(let t=0;t<e.header.length;t++)n+=this.tablecell(e.header[t]);t+=this.tablerow({text:n});let s="";for(let t=0;t<e.rows.length;t++){const r=e.rows[t];n="";for(let e=0;e<r.length;e++)n+=this.tablecell(r[e]);s+=this.tablerow({text:n})}return s&&(s=`<tbody>${s}</tbody>`),"<table>\n<thead>\n"+t+"</thead>\n"+s+"</table>\n"}tablerow({text:e}){return`<tr>\n${e}</tr>\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}>\n`}strong({tokens:e}){return`<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return`<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return`<code>${e}</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=u(e);if(null===r)return s;let i='<a href="'+(e=r)+'"';return t&&(i+=' title="'+t+'"'),i+=">"+s+"</a>",i}image({href:e,title:t,text:n}){const s=u(e);if(null===s)return n;let r=`<img src="${e=s}" alt="${n}"`;return t&&(r+=` title="${t}"`),r+=">",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):e.text}}class ne{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}}class se{options;renderer;textRenderer;constructor(t){this.options=t||e.defaults,this.options.renderer=this.options.renderer||new te,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new ne}static parse(e,t){return new se(t).parse(e)}static parseInline(e,t){return new se(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s<e.length;s++){const r=e[s];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[r.type]){const e=r,t=this.options.extensions.renderers[e.type].call({parser:this},e);if(!1!==t||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(e.type)){n+=t||"";continue}}const i=r;switch(i.type){case"space":n+=this.renderer.space(i);continue;case"hr":n+=this.renderer.hr(i);continue;case"heading":n+=this.renderer.heading(i);continue;case"code":n+=this.renderer.code(i);continue;case"table":n+=this.renderer.table(i);continue;case"blockquote":n+=this.renderer.blockquote(i);continue;case"list":n+=this.renderer.list(i);continue;case"html":n+=this.renderer.html(i);continue;case"paragraph":n+=this.renderer.paragraph(i);continue;case"text":{let r=i,l=this.renderer.text(r);for(;s+1<e.length&&"text"===e[s+1].type;)r=e[++s],l+="\n"+this.renderer.text(r);n+=t?this.renderer.paragraph({type:"paragraph",raw:l,text:l,tokens:[{type:"text",raw:l,text:l}]}):l;continue}default:{const e='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(e),"";throw new Error(e)}}}return n}parseInline(e,t){t=t||this.renderer;let n="";for(let s=0;s<e.length;s++){const r=e[s];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[r.type]){const e=this.options.extensions.renderers[r.type].call({parser:this},r);if(!1!==e||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(r.type)){n+=e||"";continue}}const i=r;switch(i.type){case"escape":case"text":n+=t.text(i);break;case"html":n+=t.html(i);break;case"link":n+=t.link(i);break;case"image":n+=t.image(i);break;case"strong":n+=t.strong(i);break;case"em":n+=t.em(i);break;case"codespan":n+=t.codespan(i);break;case"br":n+=t.br(i);break;case"del":n+=t.del(i);break;default:{const e='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(e),"";throw new Error(e)}}}return n}}class re{options;constructor(t){this.options=t||e.defaults}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}}class ie{defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};options=this.setOptions;parse=this.parseMarkdown(ee.lex,se.parse);parseInline=this.parseMarkdown(ee.lexInline,se.parseInline);Parser=se;Renderer=te;TextRenderer=ne;Lexer=ee;Tokenizer=x;Hooks=re;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(const s of e)switch(n=n.concat(t.call(this,s)),s.type){case"table":{const e=s;for(const s of e.header)n=n.concat(this.walkTokens(s.tokens,t));for(const s of e.rows)for(const e of s)n=n.concat(this.walkTokens(e.tokens,t));break}case"list":{const e=s;n=n.concat(this.walkTokens(e.items,t));break}default:{const e=s;this.defaults.extensions?.childTokens?.[e.type]?this.defaults.extensions.childTokens[e.type].forEach((s=>{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new te(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new x(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new re;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];re.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ee.lex(e,t??this.defaults)}parser(e,t){return se.parse(e,t??this.defaults)}parseMarkdown(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r},l=this.onError(!!i.silent,!!i.async);if(!0===this.defaults.async&&!1===r.async)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="<p>An error occurred:</p><pre>"+c(n.message+"",!0)+"</pre>";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const le=new ie;function oe(e,t){return le.parse(e,t)}oe.options=oe.setOptions=function(e){return le.setOptions(e),oe.defaults=le.defaults,n(oe.defaults),oe},oe.getDefaults=t,oe.defaults=e.defaults,oe.use=function(...e){return le.use(...e),oe.defaults=le.defaults,n(oe.defaults),oe},oe.walkTokens=function(e,t){return le.walkTokens(e,t)},oe.parseInline=le.parseInline,oe.Parser=se,oe.parser=se.parse,oe.Renderer=te,oe.TextRenderer=ne,oe.Lexer=ee,oe.lexer=ee.lex,oe.Tokenizer=x,oe.Hooks=re,oe.parse=oe;const ae=oe.options,ce=oe.setOptions,he=oe.use,pe=oe.walkTokens,ue=oe.parseInline,ke=oe,ge=se.parse,fe=ee.lex;e.Hooks=re,e.Lexer=ee,e.Marked=ie,e.Parser=se,e.Renderer=te,e.TextRenderer=ne,e.Tokenizer=x,e.getDefaults=t,e.lexer=fe,e.marked=oe,e.options=ae,e.parse=ke,e.parseInline=ue,e.parser=ge,e.setOptions=ce,e.use=he,e.walkTokens=pe}));
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
web/vendor/noble-ed25519.js +9 -0
……@@ -0,0 +1,9 @@
1/**
2 * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
3 * Original file: /npm/@noble/ed25519@2.0.0/index.js
4 *
5 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 */
7/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */
8const t=2n**255n-19n,e=2n**252n+27742317777372353535851937790883648493n,n=0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,r=0x6666666666666666666666666666666666666666666666666666666666666658n,s={a:-1n,d:37095705934669439343138083508754565189542113879843219016388785533085940283555n,p:t,n:e,h:8,Gx:n,Gy:r},o=(t="")=>{throw new Error(t)},i=t=>"string"==typeof t,a=(t,e)=>!(t instanceof Uint8Array)||"number"==typeof e&&e>0&&t.length!==e?o("Uint8Array expected"):t,c=t=>new Uint8Array(t),l=(t,e)=>a(i(t)?m(t):c(t),e),u=(e,n=t)=>{let r=e%n;return r>=0n?r:n+r},d=t=>t instanceof y?t:o("Point expected");let h;class y{constructor(t,e,n,r){this.ex=t,this.ey=e,this.ez=n,this.et=r}static fromAffine(t){return new y(t.x,t.y,1n,u(t.x*t.y))}static fromHex(e,n=!0){const{d:r}=s,i=(e=l(e,32)).slice();i[31]=-129&e[31];const a=w(i);0n===a||(!n||0n<a&&a<t||o("bad y coord 1"),n||0n<a&&a<2n**256n||o("bad y coord 2"));const c=u(a*a),d=u(c-1n),h=u(r*c+1n);let{isValid:f,value:p}=E(d,h);f||o("bad y coordinate 3");const x=1n===(1n&p);return!!(128&e[31])!==x&&(p=u(-p)),new y(p,a,1n,u(p*a))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}equals(t){const{ex:e,ey:n,ez:r}=this,{ex:s,ey:o,ez:i}=d(t),a=u(e*i),c=u(s*r),l=u(n*i),h=u(o*r);return a===c&&l===h}is0(){return this.equals(p)}negate(){return new y(u(-this.ex),this.ey,this.ez,u(-this.et))}double(){const{ex:t,ey:e,ez:n}=this,{a:r}=s,o=u(t*t),i=u(e*e),a=u(2n*u(n*n)),c=u(r*o),l=t+e,d=u(u(l*l)-o-i),h=c+i,f=h-a,p=c-i,x=u(d*f),b=u(h*p),m=u(d*p),g=u(f*h);return new y(x,b,g,m)}add(t){const{ex:e,ey:n,ez:r,et:o}=this,{ex:i,ey:a,ez:c,et:l}=d(t),{a:h,d:f}=s,p=u(e*i),x=u(n*a),b=u(o*f*l),m=u(r*c),g=u((e+n)*(i+a)-p-x),w=u(m-b),B=u(m+b),A=u(x-h*p),v=u(g*w),R=u(B*A),E=u(g*A),S=u(w*B);return new y(v,R,S,E)}mul(t,n=!0){if(0n===t)return!0===n?o("cannot multiply by 0"):p;if("bigint"==typeof t&&0n<t&&t<e||o("invalid scalar, must be < L"),!n&&this.is0()||1n===t)return this;if(this.equals(f))return M(t).p;let r=p,s=f;for(let e=this;t>0n;e=e.double(),t>>=1n)1n&t?r=r.add(e):n&&(s=s.add(e));return r}multiply(t){return this.mul(t)}clearCofactor(){return this.mul(BigInt(s.h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let t=this.mul(e/2n,!1).double();return t=t.add(this),t.is0()}toAffine(){const{ex:t,ey:e,ez:n}=this;if(this.is0())return{x:0n,y:0n};const r=A(n);return 1n!==u(n*r)&&o("invalid inverse"),{x:u(t*r),y:u(e*r)}}toRawBytes(){const{x:t,y:e}=this.toAffine(),n=g(e);return n[31]|=1n&t?128:0,n}toHex(){return b(this.toRawBytes())}}y.BASE=new y(n,r,1n,u(n*r)),y.ZERO=new y(0n,1n,1n,0n);const{BASE:f,ZERO:p}=y,x=(t,e)=>t.toString(16).padStart(e,"0"),b=t=>Array.from(t).map((t=>x(t,2))).join(""),m=t=>{const e=t.length;(!i(t)||e%2)&&o("hex invalid 1");const n=c(e/2);for(let e=0;e<n.length;e++){const r=2*e,s=t.slice(r,r+2),i=Number.parseInt(s,16);(Number.isNaN(i)||i<0)&&o("hex invalid 2"),n[e]=i}return n},g=t=>m(x(t,64)).reverse(),w=t=>BigInt("0x"+b(c(a(t)).reverse())),B=(...t)=>{const e=c(t.reduce(((t,e)=>t+a(e).length),0));let n=0;return t.forEach((t=>{e.set(t,n),n+=t.length})),e},A=(e,n=t)=>{(0n===e||n<=0n)&&o("no inverse n="+e+" mod="+n);let r=u(e,n),s=n,i=0n,a=1n;for(;0n!==r;){const t=s%r,e=i-a*(s/r);s=r,r=t,i=a,a=e}return 1n===s?u(i,n):o("no inverse")},v=(e,n)=>{let r=e;for(;n-- >0n;)r*=r,r%=t;return r},R=19681161376707505956807079304988542015446066515923890162744021073123829784752n,E=(e,n)=>{const r=u(n*n*n),s=(e=>{const n=e*e%t*e%t,r=v(n,2n)*n%t,s=v(r,1n)*e%t,o=v(s,5n)*s%t,i=v(o,10n)*o%t,a=v(i,20n)*i%t,c=v(a,40n)*a%t,l=v(c,80n)*c%t,u=v(l,80n)*c%t,d=v(u,10n)*o%t;return{pow_p_5_8:v(d,2n)*e%t,b2:n}})(e*u(r*r*n)).pow_p_5_8;let o=u(e*r*s);const i=u(n*o*o),a=o,c=u(o*R),l=i===e,d=i===u(-e),h=i===u(-e*R);return l&&(o=a),(d||h)&&(o=c),1n===(1n&u(o))&&(o=u(-o)),{isValid:l||d,value:o}},S=t=>u(w(t),e);let z;const H=(...t)=>F.sha512Async(...t),P=(...t)=>"function"==typeof z?z(...t):o("etc.sha512Sync not set"),T=t=>{const e=t.slice(0,32);e[0]&=248,e[31]&=127,e[31]|=64;const n=t.slice(32,64),r=S(e),s=f.mul(r),o=s.toRawBytes();return{head:e,prefix:n,scalar:r,point:s,pointBytes:o}},_=t=>H(l(t,32)).then(T),I=t=>T(P(l(t,32))),N=t=>_(t).then((t=>t.pointBytes)),O=t=>I(t).pointBytes;function V(t,e){return t?H(e.hashable).then(e.finish):e.finish(P(e.hashable))}const j=(t,n,r)=>{const{pointBytes:s,scalar:o}=t,i=S(n),c=f.mul(i).toRawBytes();return{hashable:B(c,s,r),finish:t=>{const n=u(i+S(t)*o,e);return a(B(c,g(n)),64)}}},q=async(t,e)=>{const n=l(t),r=await _(e),s=await H(r.prefix,n);return V(!0,j(r,s,n))},C=(t,e)=>{const n=l(t),r=I(e),s=P(r.prefix,n);return V(!1,j(r,s,n))},K=(t,e,n)=>{e=l(e),t=l(t,64);const r=y.fromHex(n,!1),s=y.fromHex(t.slice(0,32),!1),o=w(t.slice(32,64)),i=f.mul(o,!1);return{hashable:B(s.toRawBytes(),r.toRawBytes(),e),finish:t=>{const e=S(t);return s.add(r.mul(e,!1)).add(i.negate()).clearCofactor().is0()}}},U=async(t,e,n)=>V(!0,K(t,e,n)),G=(t,e,n)=>V(!1,K(t,e,n)),Z=()=>"object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0,F={bytesToHex:b,hexToBytes:m,concatBytes:B,mod:u,invert:A,randomBytes:t=>{const e=Z();return e||o("crypto.getRandomValues must be defined"),e.getRandomValues(c(t))},sha512Async:async(...t)=>{const e=Z();e||o("crypto.subtle or etc.sha512Async must be defined");const n=B(...t);return c(await e.subtle.digest("SHA-512",n.buffer))},sha512Sync:void 0};Object.defineProperties(F,{sha512Sync:{configurable:!1,get:()=>z,set(t){z||(z=t)}}});const L={getExtendedPublicKeyAsync:_,getExtendedPublicKey:I,randomPrivateKey:()=>F.randomBytes(32),precompute:(t=8,e=f)=>(e.multiply(3n),e)},M=t=>{const e=h||(h=(()=>{const t=[];let e=f,n=e;for(let r=0;r<33;r++){n=e,t.push(n);for(let r=1;r<128;r++)n=n.add(e),t.push(n);e=n.double()}return t})()),n=(t,e)=>{let n=e.negate();return t?n:e};let r=p,s=f;const o=BigInt(255),i=BigInt(8);for(let a=0;a<33;a++){const c=128*a;let l=Number(t&o);t>>=i,l>128&&(l-=256,t+=1n);const u=c,d=c+Math.abs(l)-1,h=a%2!=0,y=l<0;0===l?s=s.add(n(h,e[u])):r=r.add(n(y,e[d]))}return{p:r,f:s}};export{s as CURVE,y as ExtendedPoint,F as etc,O as getPublicKey,N as getPublicKeyAsync,C as sign,q as signAsync,L as utils,G as verify,U as verifyAsync};export default null;
9//# sourceMappingURL=/sm/cd01a92102818c7c46dbe0b9ea67492dd1f0856266ce83a45f80684e6e019347.map
web/vendor/noble-sha512.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/sha512.js
4 *
5 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 */
7import"./noble-crypto.js";function t(t){if(!Number.isSafeInteger(t)||t<0)throw new Error(`Wrong positive integer: ${t}`)}function e(t,...e){if(!(t instanceof Uint8Array))throw new TypeError("Expected Uint8Array");if(e.length>0&&!e.includes(t.length))throw new TypeError(`Expected Uint8Array of length ${e}, not of length=${t.length}`)}const s={number:t,bool:function(t){if("boolean"!=typeof t)throw new Error(`Expected boolean, not ${t}`)},bytes:e,hash:function(e){if("function"!=typeof e||"function"!=typeof e.create)throw new Error("Hash should be wrapped by utils.wrapConstructor");t(e.outputLen),t(e.blockLen)},exists:function(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")},output:function(t,s){e(t);const h=s.outputLen;if(t.length<h)throw new Error(`digestInto() expects output buffer of length at least ${h}`)}},h=t=>new DataView(t.buffer,t.byteOffset,t.byteLength);if(!(68===new Uint8Array(new Uint32Array([287454020]).buffer)[0]))throw new Error("Non little-endian hardware is not supported");function i(t){if("string"==typeof t&&(t=function(t){if("string"!=typeof t)throw new TypeError("utf8ToBytes expected string, got "+typeof t);return(new TextEncoder).encode(t)}(t)),!(t instanceof Uint8Array))throw new TypeError(`Expected input type is Uint8Array (got ${typeof t})`);return t}Array.from({length:256},((t,e)=>e.toString(16).padStart(2,"0")));class r{clone(){return this._cloneInto()}}function n(t){const e=e=>t().update(i(e)).digest(),s=t();return e.outputLen=s.outputLen,e.blockLen=s.blockLen,e.create=()=>t(),e}class o extends r{constructor(t,e,s,i){super(),this.blockLen=t,this.outputLen=e,this.padOffset=s,this.isLE=i,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(t),this.view=h(this.buffer)}update(t){s.exists(this);const{view:e,buffer:r,blockLen:n}=this,o=(t=i(t)).length;for(let s=0;s<o;){const i=Math.min(n-this.pos,o-s);if(i!==n)r.set(t.subarray(s,s+i),this.pos),this.pos+=i,s+=i,this.pos===n&&(this.process(e,0),this.pos=0);else{const e=h(t);for(;n<=o-s;s+=n)this.process(e,s)}}return this.length+=t.length,this.roundClean(),this}digestInto(t){s.exists(this),s.output(t,this),this.finished=!0;const{buffer:e,view:i,blockLen:r,isLE:n}=this;let{pos:o}=this;e[o++]=128,this.buffer.subarray(o).fill(0),this.padOffset>r-o&&(this.process(i,0),o=0);for(let t=o;t<r;t++)e[t]=0;!function(t,e,s,h){if("function"==typeof t.setBigUint64)return t.setBigUint64(e,s,h);const i=BigInt(32),r=BigInt(4294967295),n=Number(s>>i&r),o=Number(s&r),c=h?4:0,a=h?0:4;t.setUint32(e+c,n,h),t.setUint32(e+a,o,h)}(i,r-8,BigInt(8*this.length),n),this.process(i,0);const c=h(t),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const f=a/4,d=this.get();if(f>d.length)throw new Error("_sha2: outputLen bigger than state");for(let t=0;t<f;t++)c.setUint32(4*t,d[t],n)}digest(){const{buffer:t,outputLen:e}=this;this.digestInto(t);const s=t.slice(0,e);return this.destroy(),s}_cloneInto(t){t||(t=new this.constructor),t.set(...this.get());const{blockLen:e,buffer:s,length:h,finished:i,destroyed:r,pos:n}=this;return t.length=h,t.pos=n,t.finished=i,t.destroyed=r,h%e&&t.buffer.set(s),t}}const c=BigInt(2**32-1),a=BigInt(32);function f(t,e=!1){return e?{h:Number(t&c),l:Number(t>>a&c)}:{h:0|Number(t>>a&c),l:0|Number(t&c)}}const d={fromBig:f,split:function(t,e=!1){let s=new Uint32Array(t.length),h=new Uint32Array(t.length);for(let i=0;i<t.length;i++){const{h:r,l:n}=f(t[i],e);[s[i],h[i]]=[r,n]}return[s,h]},toBig:(t,e)=>BigInt(t>>>0)<<a|BigInt(e>>>0),shrSH:(t,e,s)=>t>>>s,shrSL:(t,e,s)=>t<<32-s|e>>>s,rotrSH:(t,e,s)=>t>>>s|e<<32-s,rotrSL:(t,e,s)=>t<<32-s|e>>>s,rotrBH:(t,e,s)=>t<<64-s|e>>>s-32,rotrBL:(t,e,s)=>t>>>s-32|e<<64-s,rotr32H:(t,e)=>e,rotr32L:(t,e)=>t,rotlSH:(t,e,s)=>t<<s|e>>>32-s,rotlSL:(t,e,s)=>e<<s|t>>>32-s,rotlBH:(t,e,s)=>e<<s-32|t>>>64-s,rotlBL:(t,e,s)=>t<<s-32|e>>>64-s,add:function(t,e,s,h){const i=(e>>>0)+(h>>>0);return{h:t+s+(i/2**32|0)|0,l:0|i}},add3L:(t,e,s)=>(t>>>0)+(e>>>0)+(s>>>0),add3H:(t,e,s,h)=>e+s+h+(t/2**32|0)|0,add4L:(t,e,s,h)=>(t>>>0)+(e>>>0)+(s>>>0)+(h>>>0),add4H:(t,e,s,h,i)=>e+s+h+i+(t/2**32|0)|0,add5H:(t,e,s,h,i,r)=>e+s+h+i+r+(t/2**32|0)|0,add5L:(t,e,s,h,i)=>(t>>>0)+(e>>>0)+(s>>>0)+(h>>>0)+(i>>>0)},[l,b]=d.split(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map((t=>BigInt(t)))),u=new Uint32Array(80),x=new Uint32Array(80);class p extends o{constructor(){super(128,64,16,!1),this.Ah=1779033703,this.Al=-205731576,this.Bh=-1150833019,this.Bl=-2067093701,this.Ch=1013904242,this.Cl=-23791573,this.Dh=-1521486534,this.Dl=1595750129,this.Eh=1359893119,this.El=-1377402159,this.Fh=-1694144372,this.Fl=725511199,this.Gh=528734635,this.Gl=-79577749,this.Hh=1541459225,this.Hl=327033209}get(){const{Ah:t,Al:e,Bh:s,Bl:h,Ch:i,Cl:r,Dh:n,Dl:o,Eh:c,El:a,Fh:f,Fl:d,Gh:l,Gl:b,Hh:u,Hl:x}=this;return[t,e,s,h,i,r,n,o,c,a,f,d,l,b,u,x]}set(t,e,s,h,i,r,n,o,c,a,f,d,l,b,u,x){this.Ah=0|t,this.Al=0|e,this.Bh=0|s,this.Bl=0|h,this.Ch=0|i,this.Cl=0|r,this.Dh=0|n,this.Dl=0|o,this.Eh=0|c,this.El=0|a,this.Fh=0|f,this.Fl=0|d,this.Gh=0|l,this.Gl=0|b,this.Hh=0|u,this.Hl=0|x}process(t,e){for(let s=0;s<16;s++,e+=4)u[s]=t.getUint32(e),x[s]=t.getUint32(e+=4);for(let t=16;t<80;t++){const e=0|u[t-15],s=0|x[t-15],h=d.rotrSH(e,s,1)^d.rotrSH(e,s,8)^d.shrSH(e,s,7),i=d.rotrSL(e,s,1)^d.rotrSL(e,s,8)^d.shrSL(e,s,7),r=0|u[t-2],n=0|x[t-2],o=d.rotrSH(r,n,19)^d.rotrBH(r,n,61)^d.shrSH(r,n,6),c=d.rotrSL(r,n,19)^d.rotrBL(r,n,61)^d.shrSL(r,n,6),a=d.add4L(i,c,x[t-7],x[t-16]),f=d.add4H(a,h,o,u[t-7],u[t-16]);u[t]=0|f,x[t]=0|a}let{Ah:s,Al:h,Bh:i,Bl:r,Ch:n,Cl:o,Dh:c,Dl:a,Eh:f,El:p,Fh:g,Fl:w,Gh:L,Gl:H,Hh:y,Hl:B}=this;for(let t=0;t<80;t++){const e=d.rotrSH(f,p,14)^d.rotrSH(f,p,18)^d.rotrBH(f,p,41),E=d.rotrSL(f,p,14)^d.rotrSL(f,p,18)^d.rotrBL(f,p,41),A=f&g^~f&L,S=p&w^~p&H,C=d.add5L(B,E,S,b[t],x[t]),U=d.add5H(C,y,e,A,l[t],u[t]),D=0|C,F=d.rotrSH(s,h,28)^d.rotrBH(s,h,34)^d.rotrBH(s,h,39),G=d.rotrSL(s,h,28)^d.rotrBL(s,h,34)^d.rotrBL(s,h,39),m=s&i^s&n^i&n,I=h&r^h&o^r&o;y=0|L,B=0|H,L=0|g,H=0|w,g=0|f,w=0|p,({h:f,l:p}=d.add(0|c,0|a,0|U,0|D)),c=0|n,a=0|o,n=0|i,o=0|r,i=0|s,r=0|h;const N=d.add3L(D,G,I);s=d.add3H(N,U,F,m),h=0|N}({h:s,l:h}=d.add(0|this.Ah,0|this.Al,0|s,0|h)),({h:i,l:r}=d.add(0|this.Bh,0|this.Bl,0|i,0|r)),({h:n,l:o}=d.add(0|this.Ch,0|this.Cl,0|n,0|o)),({h:c,l:a}=d.add(0|this.Dh,0|this.Dl,0|c,0|a)),({h:f,l:p}=d.add(0|this.Eh,0|this.El,0|f,0|p)),({h:g,l:w}=d.add(0|this.Fh,0|this.Fl,0|g,0|w)),({h:L,l:H}=d.add(0|this.Gh,0|this.Gl,0|L,0|H)),({h:y,l:B}=d.add(0|this.Hh,0|this.Hl,0|y,0|B)),this.set(s,h,i,r,n,o,c,a,f,p,g,w,L,H,y,B)}roundClean(){u.fill(0),x.fill(0)}destroy(){this.buffer.fill(0),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}}class g extends p{constructor(){super(),this.Ah=-1942145080,this.Al=424955298,this.Bh=1944164710,this.Bl=-1982016298,this.Ch=502970286,this.Cl=855612546,this.Dh=1738396948,this.Dl=1479516111,this.Eh=258812777,this.El=2077511080,this.Fh=2011393907,this.Fl=79989058,this.Gh=1067287976,this.Gl=1780299464,this.Hh=286451373,this.Hl=-1848208735,this.outputLen=28}}class w extends p{constructor(){super(),this.Ah=573645204,this.Al=-64227540,this.Bh=-1621794909,this.Bl=-934517566,this.Ch=596883563,this.Cl=1867755857,this.Dh=-1774684391,this.Dl=1497426621,this.Eh=-1775747358,this.El=-1467023389,this.Fh=-1101128155,this.Fl=1401305490,this.Gh=721525244,this.Gl=746961066,this.Hh=246885852,this.Hl=-2117784414,this.outputLen=32}}class L extends p{constructor(){super(),this.Ah=-876896931,this.Al=-1056596264,this.Bh=1654270250,this.Bl=914150663,this.Ch=-1856437926,this.Cl=812702999,this.Dh=355462360,this.Dl=-150054599,this.Eh=1731405415,this.El=-4191439,this.Fh=-1900787065,this.Fl=1750603025,this.Gh=-619958771,this.Gl=1694076839,this.Hh=1203062813,this.Hl=-1090891868,this.outputLen=48}}const H=n((()=>new p)),y=n((()=>new g)),B=n((()=>new w)),E=n((()=>new L));export{p as SHA512,E as sha384,H as sha512,y as sha512_224,B as sha512_256};export default null;
8//# sourceMappingURL=/sm/25ef7ec32d52ab376d05edb8d663f6f9885235f7c4d8ebc8cb0aab306fd143bb.map