| … | … | @@ -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 | ||
| 9 | await import('bare-node-runtime/global') | |
| 10 | ||
| 11 | import fs from 'bare-fs' | |
| 12 | import path from 'bare-path' | |
| 13 | import { HyperBookingServer } from './lib/server.js' | |
| 14 | ||
| 15 | // Version | |
| 16 | let 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 | ||
| 25 | process.on('uncaughtException', (err) => { | |
| 26 | console.error('[FATAL] Uncaught exception:', err.message, err.stack) | |
| 27 | }) | |
| 28 | process.on('unhandledRejection', (reason) => { | |
| 29 | console.error('[WARN] Unhandled rejection:', reason && reason.message ? reason.message : reason) | |
| 30 | }) | |
| 31 | ||
| 32 | // Parse args | |
| 33 | const rawArgs = process.argv.slice(2) | |
| 34 | const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs | |
| 35 | const command = args[0] || 'serve' | |
| 36 | ||
| 37 | if (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 | } |
| … | … | @@ -0,0 +1,375 @@ |
| 1 | import { IdentityRegistry } from './auth/identity-registry.js' | |
| 2 | import { WebIdentityProvider } from './auth/providers/web-identity.js' | |
| 3 | import { BearerTokenProvider } from './auth/providers/bearer-token.js' | |
| 4 | import { KeetIdentityProvider } from './auth/providers/keet-identity.js' | |
| 5 | import { ChallengeManager } from './auth/challenge-manager.js' | |
| 6 | import { 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 | */ | |
| 21 | export 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 | */ | |
| 358 | export const Permissions = { | |
| 359 | READ: 'read', | |
| 360 | WRITE: 'write', | |
| 361 | ADMIN: 'admin' | |
| 362 | } | |
| 363 | ||
| 364 | /** | |
| 365 | * Helper to send auth error response | |
| 366 | */ | |
| 367 | export 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 | } |
| … | … | @@ -0,0 +1,339 @@ |
| 1 | import crypto from 'hypercore-crypto' | |
| 2 | import 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 | */ | |
| 14 | export 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 | } |
| … | … | @@ -0,0 +1,648 @@ |
| 1 | import 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 | ||
| 20 | const RESERVED_USERNAMES = [ | |
| 21 | 'api', 'admin', 'web', 'sync', 'static', 'assets', 'auth', | |
| 22 | 'health', 'status', 'login', 'logout', 'register', 'settings' | |
| 23 | ] | |
| 24 | ||
| 25 | const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/ | |
| 26 | export 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 | } |
| … | … | @@ -0,0 +1,232 @@ |
| 1 | import { AuthProvider } from '../base-provider.js' | |
| 2 | import 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 | */ | |
| 13 | export 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 | } |
| … | … | @@ -0,0 +1,149 @@ |
| 1 | import { AuthProvider } from '../base-provider.js' | |
| 2 | import IdentityKey from 'keet-identity-key' | |
| 3 | import crypto from 'hypercore-crypto' | |
| 4 | import 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 | */ | |
| 15 | export 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 | } |
| … | … | @@ -0,0 +1,174 @@ |
| 1 | import { AuthProvider } from '../base-provider.js' | |
| 2 | import { 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 | */ | |
| 13 | const DEBUG = typeof process !== 'undefined' && process.env?.DEBUG | |
| 14 | ||
| 15 | export 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 | } |
| … | … | @@ -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 | */ | |
| 11 | export 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 | } |
| … | … | @@ -0,0 +1,83 @@ |
| 1 | /** | |
| 2 | * Error Classes for hyper-booking | |
| 3 | * Adapted from hypernote/pear-git pattern. | |
| 4 | */ | |
| 5 | ||
| 6 | export 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 | ||
| 23 | export 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 | ||
| 35 | export 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 | ||
| 43 | export class UnauthorizedError extends AppError { | |
| 44 | constructor (message = 'Authentication required') { | |
| 45 | super(message, 'UNAUTHORIZED', 401) | |
| 46 | this.name = 'UnauthorizedError' | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export 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 | ||
| 58 | export class ConflictError extends AppError { | |
| 59 | constructor (message = 'Resource conflict') { | |
| 60 | super(message, 'CONFLICT', 409) | |
| 61 | this.name = 'ConflictError' | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export 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 | ||
| 73 | export function isAppError (err) { | |
| 74 | return err instanceof AppError | |
| 75 | } | |
| 76 | ||
| 77 | export 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 | } |
| … | … | @@ -0,0 +1,84 @@ |
| 1 | /** | |
| 2 | * Response Helpers — standardized HTTP response utilities. | |
| 3 | * Same pattern as hypernote. | |
| 4 | */ | |
| 5 | ||
| 6 | export 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 | ||
| 13 | export 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 | ||
| 22 | export function text (res, content, status = 200) { | |
| 23 | res.statusCode = status | |
| 24 | res.setHeader('Content-Type', 'text/plain') | |
| 25 | res.end(content) | |
| 26 | } | |
| 27 | ||
| 28 | export 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 | ||
| 34 | export function notFound (res, message = 'Not found') { | |
| 35 | error(res, message, 404) | |
| 36 | } | |
| 37 | ||
| 38 | export function badRequest (res, message = 'Bad request') { | |
| 39 | error(res, message, 400) | |
| 40 | } | |
| 41 | ||
| 42 | export 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 | ||
| 49 | const MAX_BODY_SIZE = 1024 * 1024 // 1MB | |
| 50 | ||
| 51 | export 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 | ||
| 64 | export async function readJson (req) { | |
| 65 | const body = await readBody(req) | |
| 66 | if (!body) return {} | |
| 67 | return JSON.parse(body) | |
| 68 | } | |
| 69 | ||
| 70 | export function escapeHtml (text) { | |
| 71 | if (!text) return '' | |
| 72 | return String(text) | |
| 73 | .replace(/&/g, '&') | |
| 74 | .replace(/</g, '<') | |
| 75 | .replace(/>/g, '>') | |
| 76 | .replace(/"/g, '"') | |
| 77 | .replace(/'/g, ''') | |
| 78 | } | |
| 79 | ||
| 80 | export function redirect (res, url, status = 302) { | |
| 81 | res.statusCode = status | |
| 82 | res.setHeader('Location', url) | |
| 83 | res.end() | |
| 84 | } |
| … | … | @@ -0,0 +1,117 @@ |
| 1 | /** | |
| 2 | * Router — pattern-based HTTP router. | |
| 3 | * Same pattern as hypernote/pear-git. | |
| 4 | */ | |
| 5 | ||
| 6 | import { isAppError } from './errors.js' | |
| 7 | ||
| 8 | export 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 | } |
| … | … | @@ -0,0 +1,20 @@ |
| 1 | /** | |
| 2 | * Shared auth helpers for route handlers. | |
| 3 | */ | |
| 4 | ||
| 5 | import { UnauthorizedError, ForbiddenError } from '../errors.js' | |
| 6 | ||
| 7 | export 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 | ||
| 14 | export async function optionalAuth (req, server) { | |
| 15 | try { | |
| 16 | return await server.authManager.authenticate(req) | |
| 17 | } catch { | |
| 18 | return null | |
| 19 | } | |
| 20 | } |
| … | … | @@ -0,0 +1,58 @@ |
| 1 | /** | |
| 2 | * Auth routes — challenge endpoint + identity management. | |
| 3 | */ | |
| 4 | ||
| 5 | import { json, readJson } from '../response.js' | |
| 6 | ||
| 7 | export 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 | } |
| … | … | @@ -0,0 +1,209 @@ |
| 1 | /** | |
| 2 | * Availability routes — provider schedule + slot calculation. | |
| 3 | */ | |
| 4 | ||
| 5 | import { json, readJson, notFound } from '../response.js' | |
| 6 | import { requireAuth } from './auth-helpers.js' | |
| 7 | ||
| 8 | export 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 | ||
| 108 | export 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 | ||
| 175 | async 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 | ||
| 187 | function 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 | ||
| 193 | function timeToMs (timeStr) { | |
| 194 | const [h, m] = timeStr.split(':').map(Number) | |
| 195 | return (h * 60 + m) * 60000 | |
| 196 | } | |
| 197 | ||
| 198 | function 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 | ||
| 205 | function 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 | } |
| … | … | @@ -0,0 +1,290 @@ |
| 1 | /** | |
| 2 | * Booking routes — create, confirm, cancel, reschedule. | |
| 3 | */ | |
| 4 | ||
| 5 | import { json, readJson, notFound } from '../response.js' | |
| 6 | import { requireAuth, optionalAuth } from './auth-helpers.js' | |
| 7 | import { calculateSlots } from './availability.js' | |
| 8 | ||
| 9 | export 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 | ||
| 254 | function 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 | ||
| 262 | async 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 | ||
| 287 | async function isProviderOwner (server, providerId, userId) { | |
| 288 | const entry = await server.db.get(`provider:${providerId}`) | |
| 289 | return entry && entry.value.userId === userId | |
| 290 | } |
| … | … | @@ -0,0 +1,149 @@ |
| 1 | /** | |
| 2 | * Package routes — CRUD for service packages. | |
| 3 | */ | |
| 4 | ||
| 5 | import { json, readJson, notFound } from '../response.js' | |
| 6 | import { requireAuth } from './auth-helpers.js' | |
| 7 | ||
| 8 | export 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 | } |
| … | … | @@ -0,0 +1,106 @@ |
| 1 | /** | |
| 2 | * Provider routes — CRUD for provider profiles. | |
| 3 | */ | |
| 4 | ||
| 5 | import { json, readJson, notFound } from '../response.js' | |
| 6 | import { requireAuth } from './auth-helpers.js' | |
| 7 | ||
| 8 | export 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 | } |
| … | … | @@ -0,0 +1,243 @@ |
| 1 | /** | |
| 2 | * Web routes — static files + public provider pages. | |
| 3 | */ | |
| 4 | ||
| 5 | import fs from 'bare-fs' | |
| 6 | import path from 'bare-path' | |
| 7 | import { html, notFound, json } from '../response.js' | |
| 8 | ||
| 9 | const 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 | ||
| 23 | export 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 | ||
| 47 | export 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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;">← ${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 | ||
| 150 | function 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 | ||
| 166 | function formatPrice (cents) { | |
| 167 | if (cents === 0) return 'Free' | |
| 168 | return '$' + (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2) | |
| 169 | } | |
| 170 | ||
| 171 | function renderProviderPage (provider, packages, prefix) { | |
| 172 | const accent = provider.accentColor || '#c4a265' | |
| 173 | const esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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 | } |
| … | … | @@ -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 | ||
| 11 | import http from 'bare-http1' | |
| 12 | import fs from 'bare-fs' | |
| 13 | import path from 'bare-path' | |
| 14 | import os from 'bare-os' | |
| 15 | import Corestore from 'corestore' | |
| 16 | import Hyperbee from 'hyperbee' | |
| 17 | import crypto from 'bare-crypto' | |
| 18 | ||
| 19 | import { AuthManager } from './auth.js' | |
| 20 | import { Router } from './router.js' | |
| 21 | import { json, notFound, escapeHtml } from './response.js' | |
| 22 | import { registerProviderRoutes } from './routes/providers.js' | |
| 23 | import { registerPackageRoutes } from './routes/packages.js' | |
| 24 | import { registerAvailabilityRoutes } from './routes/availability.js' | |
| 25 | import { registerBookingRoutes } from './routes/bookings.js' | |
| 26 | import { registerAuthRoutes } from './routes/auth.js' | |
| 27 | import { registerWebRoutes, registerPublicRoutes } from './routes/web.js' | |
| 28 | ||
| 29 | export 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 | } |
| … | … | @@ -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 | } |
| … | … | @@ -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 | ||
| 8 | await import('bare-node-runtime/global') | |
| 9 | ||
| 10 | import path from 'bare-path' | |
| 11 | import os from 'bare-os' | |
| 12 | import Corestore from 'corestore' | |
| 13 | import Hyperbee from 'hyperbee' | |
| 14 | import crypto from 'bare-crypto' | |
| 15 | ||
| 16 | const args = process.argv.slice(2) | |
| 17 | let storage = path.join(os.homedir(), '.config', 'hyper-booking', 'data') | |
| 18 | for (const arg of args) { | |
| 19 | if (arg.startsWith('--storage=')) storage = arg.split('=')[1] | |
| 20 | } | |
| 21 | ||
| 22 | console.log('Seeding into:', storage) | |
| 23 | ||
| 24 | const store = new Corestore(storage) | |
| 25 | const core = store.get({ name: 'registry' }) | |
| 26 | const db = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' }) | |
| 27 | await db.ready() | |
| 28 | ||
| 29 | function genId (prefix) { | |
| 30 | return prefix + '_' + crypto.randomBytes(12).toString('hex') | |
| 31 | } | |
| 32 | ||
| 33 | // Create test provider | |
| 34 | const providerId = genId('prov') | |
| 35 | const 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) | |
| 51 | const 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 | ||
| 65 | const 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 | ||
| 79 | const 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 | ||
| 93 | const 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) | |
| 110 | const 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 | |
| 130 | const batch = db.batch() | |
| 131 | ||
| 132 | await batch.put(`provider:${providerId}`, provider) | |
| 133 | await batch.put(`provider-by-slug:${provider.slug}`, providerId) | |
| 134 | ||
| 135 | for (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 | ||
| 140 | await batch.put(`availability:${providerId}`, availability) | |
| 141 | await batch.put('meta:initialized', { at: Date.now(), version: '0.1.0' }) | |
| 142 | ||
| 143 | await batch.flush() | |
| 144 | ||
| 145 | console.log('Seeded:') | |
| 146 | console.log(` Provider: ${provider.displayName} (/${provider.slug})`) | |
| 147 | console.log(` Packages: ${[pkgDiscovery, pkgSingle, pkg60, pkgBundle].map(p => p.name).join(', ')}`) | |
| 148 | console.log(` Availability: Mon-Fri`) | |
| 149 | console.log('') | |
| 150 | console.log(`Start server: bare index.js serve`) | |
| 151 | console.log(`Visit: http://localhost:4050/${provider.slug}`) | |
| 152 | ||
| 153 | await store.close() | |
| 154 | process.exit(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 | ||
| 18 | body { | |
| 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 */ | |
| 250 | input, 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 | } | |
| 259 | input: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 | ||
| 265 | label { | |
| 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 | } |
| … | … | @@ -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 | ||
| 10 | function 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 | ||
| 18 | async 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 | ||
| 31 | function 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 | ||
| 38 | function 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 | ||
| 43 | function formatPrice (cents) { | |
| 44 | if (cents === 0) return 'Free' | |
| 45 | return '$' + (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2) | |
| 46 | } | |
| 47 | ||
| 48 | // ---- Sign In ---- | |
| 49 | ||
| 50 | const signInBtn = document.getElementById('sign-in-btn') | |
| 51 | if (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 | ||
| 73 | async 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">←</button> | |
| 94 | <span>${formatDate(weekStr)} – ${formatDate(weekEnd.toISOString().split('T')[0])}</span> | |
| 95 | <button id="next-week">→</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 | |
| 184 | window.initBookingPage = initBookingPage | |
| 185 | window.showToast = showToast |
| … | … | @@ -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 | ||
| 10 | window.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 | })() |
| … | … | @@ -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={"&":"&","<":"<",">":">",'"':""","'":"'"},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})); |
| … | … | @@ -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 | */ | |
| 7 | const o="object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0;export{o as crypto};export default null; | |
| 8 | //# sourceMappingURL=/sm/c89c3eead1f3ff4004cc3626795fb3273c573384fdf3d96f44a89e5143a967f7.map |
| … | … | @@ -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) */ | |
| 8 | const 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 |
| … | … | @@ -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 | */ | |
| 7 | import"./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 |