/** * EncryptID Server * * Handles WebAuthn registration/authentication, session management, * and serves the .well-known/webauthn configuration. * * Storage: PostgreSQL (via db.ts) */ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { serveStatic } from 'hono/bun'; import { sign, verify } from 'hono/jwt'; import { createTransport, type Transporter } from 'nodemailer'; import { initDatabase, storeCredential, getCredential, updateCredentialUsage, getUserCredentials, storeChallenge, getChallenge, deleteChallenge, cleanExpiredChallenges, cleanExpiredRecoveryTokens, checkDatabaseHealth, createUser, setUserEmail, getUserByEmail, getUserById, getUserByUsername, storeRecoveryToken, getRecoveryToken, markRecoveryTokenUsed, type StoredCredential, type StoredChallenge, type StoredRecoveryToken, addGuardian, getGuardians, getGuardianByInviteToken, acceptGuardianInvite, removeGuardian, getGuardianById, getGuardianships, createRecoveryRequest, getRecoveryRequest, getActiveRecoveryRequest, createRecoveryApproval, approveRecoveryByToken, updateRecoveryRequestStatus, getRecoveryApprovals, createDeviceLink, getDeviceLink, markDeviceLinkUsed, getSpaceMember, listSpaceMembers, listSpacesForUser, upsertSpaceMember, removeSpaceMember, getUserProfile, updateUserProfile, getUserAddresses, getAddressById, saveUserAddress, deleteUserAddress, getEmailForwardStatus, setEmailForward, listAllUsers, deleteUser, deleteSpaceMembers, createSpaceInvite, getSpaceInviteByToken, listSpaceInvites, acceptSpaceInvite, revokeSpaceInvite, createFundClaim, getFundClaimByToken, getFundClaimsByEmailHash, acceptFundClaim, accumulateFundClaim, cleanExpiredFundClaims, getOidcClient, createOidcAuthCode, consumeOidcAuthCode, cleanExpiredOidcCodes, seedOidcClients, createIdentityInvite, getIdentityInviteByToken, getIdentityInvitesByInviter, claimIdentityInvite, revokeIdentityInvite, cleanExpiredIdentityInvites, sql, } from './db.js'; import { isMailcowConfigured, createAlias, deleteAlias, updateAlias, aliasExists, } from './mailcow.js'; import { notify } from '../../server/notification-service'; // ============================================================================ // CONFIGURATION // ============================================================================ const CONFIG = { port: process.env.PORT || 3000, rpId: 'rspace.online', rpName: 'EncryptID', jwtSecret: (() => { const secret = process.env.JWT_SECRET; if (!secret) throw new Error('JWT_SECRET environment variable is required'); return secret; })(), sessionDuration: 30 * 24 * 60 * 60, // 30 days refreshDuration: 7 * 24 * 60 * 60, // 7 days smtp: { host: process.env.SMTP_HOST || 'mail.rmail.online', port: parseInt(process.env.SMTP_PORT || '587'), secure: false, // STARTTLS on 587 user: process.env.SMTP_USER || 'noreply@rspace.online', pass: process.env.SMTP_PASS || '', from: process.env.SMTP_FROM || 'EncryptID ', }, recoveryUrl: process.env.RECOVERY_URL || 'https://auth.rspace.online/recover', adminDIDs: (process.env.ADMIN_DIDS || '').split(',').filter(Boolean), allowedOrigins: [ // rspace.online — RP ID domain and all subdomains 'https://rspace.online', 'https://auth.rspace.online', 'https://cca.rspace.online', 'https://demo.rspace.online', 'https://app.rspace.online', 'https://dev.rspace.online', // r* ecosystem apps (each *.online is an eTLD+1 — Related Origins limit is 5) 'https://rwallet.online', 'https://rvote.online', 'https://rmaps.online', 'https://rfiles.online', 'https://rnotes.online', 'https://rflows.online', 'https://rtrips.online', 'https://rnetwork.online', 'https://rcart.online', 'https://rtube.online', 'https://rchats.online', 'https://rstack.online', 'https://rpubs.online', 'https://rauctions.online', 'https://ridentity.online', 'https://auth.ridentity.online', 'https://rphotos.online', 'https://rcal.online', 'https://rinbox.online', 'https://rmail.online', 'https://rsocials.online', 'https://demo.rsocials.online', 'https://socials.crypto-commons.org', 'https://socials.p2pfoundation.net', 'https://rwork.online', 'https://rforum.online', 'https://rchoices.online', 'https://rswag.online', 'https://rdata.online', // Development 'http://localhost:3000', 'http://localhost:5173', ], }; // ============================================================================ // SMTP TRANSPORT // ============================================================================ let smtpTransport: Transporter | null = null; if (CONFIG.smtp.pass) { smtpTransport = createTransport({ host: CONFIG.smtp.host, port: CONFIG.smtp.port, secure: CONFIG.smtp.port === 465, auth: { user: CONFIG.smtp.user, pass: CONFIG.smtp.pass, }, tls: { rejectUnauthorized: false, // Internal Mailcow uses self-signed cert }, }); // Verify connection on startup smtpTransport.verify().then(() => { console.log('EncryptID: SMTP connected to', CONFIG.smtp.host); }).catch((err) => { console.error('EncryptID: SMTP connection failed —', err.message); console.error('EncryptID: Email recovery will not work until SMTP is configured'); smtpTransport = null; }); } else { console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)'); } async function sendRecoveryEmail(to: string, token: string, username: string): Promise { const recoveryLink = `${CONFIG.recoveryUrl}?token=${encodeURIComponent(token)}`; if (!smtpTransport) { console.log(`EncryptID: [NO SMTP] Recovery link for ${to}: ${recoveryLink}`); return false; } await smtpTransport.sendMail({ from: CONFIG.smtp.from, to, subject: 'rStack — Account Recovery', text: [ `Hi ${username},`, '', 'A recovery request was made for your rStack account.', 'Use the link below to add a new passkey:', '', recoveryLink, '', 'This link expires in 30 minutes.', 'If you did not request this, you can safely ignore this email.', '', '— rStack Identity', ].join('\n'), html: `
🔒

rStack Identity

Hi ${username},

A recovery request was made for your rStack account. Click below to add a new passkey:

Recover Account

This link expires in 30 minutes.

If you didn't request this, you can safely ignore this email — your account is secure.

Can't click the button? Copy this link:
${recoveryLink}

`, }); return true; } async function sendClaimEmail(to: string, token: string, amount?: string, currency?: string): Promise { const claimLink = `https://auth.rspace.online/claim?token=${encodeURIComponent(token)}`; const amountStr = amount && currency ? `$${amount} ${currency}` : 'Your funds'; if (!smtpTransport) { console.log(`EncryptID: [NO SMTP] Claim link for ${to}: ${claimLink}`); return false; } await smtpTransport.sendMail({ from: CONFIG.smtp.from, to, subject: 'rStack — Funds ready to claim', text: [ `Hi,`, '', `${amountStr} are ready to claim via EncryptID.`, 'Click the link below to sign in and link your funded wallet to your account:', '', claimLink, '', 'This link expires in 7 days.', '', '— rStack Identity', ].join('\n'), html: `
💰

Funds Ready to Claim

${amountStr} have been deposited into a wallet for you.

Sign in with EncryptID to link this wallet to your account — no keys or seed phrases needed.

Claim Your Funds

This link expires in 7 days.

If you didn't expect this, you can safely ignore this email.

Can't click the button? Copy this link:
${claimLink}

`, }); return true; } async function hashEmail(email: string): Promise { const data = new TextEncoder().encode(email.toLowerCase().trim()); const hashBuffer = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); } // ============================================================================ // HONO APP // ============================================================================ const app = new Hono(); // Middleware app.use('*', logger()); app.use('*', cors({ origin: (origin) => { // Allow all *.rspace.online subdomains dynamically (any canvas slug) if (origin === 'https://rspace.online' || origin?.endsWith('.rspace.online')) { return origin; } // Allow explicit r* ecosystem origins if (CONFIG.allowedOrigins.includes(origin)) { return origin; } return undefined; }, allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], credentials: true, })); // ============================================================================ // STATIC FILES & WELL-KNOWN // ============================================================================ // Serve .well-known/webauthn for Related Origins // Only list non-rspace.online origins here — *.rspace.online subdomains are // automatically valid because rspace.online is the RP ID. // Keep to max 5 eTLD+1 labels to stay within browser limits. app.get('/.well-known/webauthn', (c) => { const nonRspaceOrigins = CONFIG.allowedOrigins.filter( o => o.startsWith('https://') && !o.endsWith('.rspace.online') && o !== 'https://rspace.online' ); return c.json({ origins: nonRspaceOrigins }); }); // Health check — includes database connectivity app.get('/health', async (c) => { const dbHealthy = await checkDatabaseHealth(); const status = dbHealthy ? 'ok' : 'degraded'; return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503); }); // ============================================================================ // RP ID RESOLUTION // ============================================================================ /** * Resolve RP ID for WebAuthn ceremonies. * * Always returns 'rspace.online' so that all passkeys are registered with * the same RP ID. The .well-known/webauthn endpoint lists Related Origins, * allowing browsers on other r*.online domains to use these passkeys. */ function resolveRpId(_c: any): string { return CONFIG.rpId; // Always 'rspace.online' } // ============================================================================ // REGISTRATION ENDPOINTS // ============================================================================ /** * Start registration - returns challenge and options */ app.post('/api/register/start', async (c) => { const { username, displayName } = await c.req.json(); if (!username) { return c.json({ error: 'Username required' }, 400); } // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Generate user ID const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Store challenge in database const challengeRecord: StoredChallenge = { challenge, userId, type: 'registration', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes }; await storeChallenge(challengeRecord); // Build registration options — use the caller's domain as RP ID const rpId = resolveRpId(c); const options = { challenge, rp: { id: rpId, name: CONFIG.rpName, }, user: { id: userId, name: username, displayName: displayName || username, }, pubKeyCredParams: [ { alg: -7, type: 'public-key' }, // ES256 { alg: -257, type: 'public-key' }, // RS256 ], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required', }, timeout: 60000, attestation: 'none', extensions: { credProps: true, }, }; return c.json({ options, userId }); }); /** * Complete registration - verify and store credential */ app.post('/api/register/complete', async (c) => { const { challenge, credential, userId, username, email } = await c.req.json(); // Verify challenge const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'registration') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); // In production, verify the attestation properly // For now, we trust the client-side verification // Create user and store credential in database const did = `did:key:${userId.slice(0, 32)}`; await createUser(userId, username, username, did); // Set recovery email if provided during registration if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { await setUserEmail(userId, email); } // Resolve the RP ID from the caller's origin const rpId = resolveRpId(c); const storedCredential: StoredCredential = { credentialId: credential.credentialId, publicKey: credential.publicKey, userId, username, counter: 0, createdAt: Date.now(), transports: credential.transports, rpId, }; await storeCredential(storedCredential); console.log('EncryptID: Credential registered', { credentialId: credential.credentialId.slice(0, 20) + '...', userId: userId.slice(0, 20) + '...', }); // Auto-provision user space at .rspace.online try { const { communityExists, createCommunity } = await import('../../server/community-store'); const { DEFAULT_USER_NEST_POLICY, DEFAULT_USER_MODULES } = await import('../../server/community-store'); const { getAllModules } = await import('../../shared/module'); const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-'); if (!await communityExists(spaceSlug)) { await createCommunity(username, spaceSlug, did, 'private', { enabledModules: DEFAULT_USER_MODULES, nestPolicy: DEFAULT_USER_NEST_POLICY, }); // Fire module hooks for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(spaceSlug); } catch (e) { console.error(`[EncryptID] Module ${mod.id} onSpaceCreate for user space:`, e); } } } console.log(`EncryptID: Auto-provisioned space ${spaceSlug}.rspace.online for ${username}`); } } catch (e) { // Non-fatal: user is created even if space provisioning fails console.error('EncryptID: Failed to auto-provision user space:', e); } // Generate initial session token const token = await generateSessionToken(userId, username); return c.json({ success: true, userId, token, did, spaceSlug: username.toLowerCase().replace(/[^a-z0-9-]/g, '-'), }); }); // ============================================================================ // AUTHENTICATION ENDPOINTS // ============================================================================ /** * Start authentication - returns challenge */ app.post('/api/auth/start', async (c) => { const body = await c.req.json().catch(() => ({})); const { credentialId } = body; // Generate challenge const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); // Store challenge in database const challengeRecord: StoredChallenge = { challenge, type: 'authentication', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, }; await storeChallenge(challengeRecord); // Build allowed credentials if specified let allowCredentials; if (credentialId) { const cred = await getCredential(credentialId); if (cred) { allowCredentials = [{ type: 'public-key', id: credentialId, transports: cred.transports, }]; } } // Use the caller's domain as RP ID const rpId = resolveRpId(c); const options = { challenge, rpId, userVerification: 'required', timeout: 60000, allowCredentials, }; return c.json({ options }); }); /** * Complete authentication - verify and issue token */ app.post('/api/auth/complete', async (c) => { const { challenge, credential } = await c.req.json(); // Verify challenge from database const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'authentication') { return c.json({ error: 'Invalid challenge' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); // Look up credential from database const storedCredential = await getCredential(credential.credentialId); if (!storedCredential) { return c.json({ error: 'Unknown credential' }, 400); } // In production, verify signature against stored public key // For now, we trust the client-side verification // Update counter and last used in database await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1); console.log('EncryptID: Authentication successful', { credentialId: credential.credentialId.slice(0, 20) + '...', userId: storedCredential.userId.slice(0, 20) + '...', }); // Generate session token const token = await generateSessionToken( storedCredential.userId, storedCredential.username ); return c.json({ success: true, userId: storedCredential.userId, username: storedCredential.username, token, did: `did:key:${storedCredential.userId.slice(0, 32)}`, }); }); // ============================================================================ // SESSION ENDPOINTS // ============================================================================ /** * Verify session token (supports both GET with Authorization header and POST with body) */ app.get('/api/session/verify', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ valid: false, error: 'No token' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return c.json({ valid: true, userId: payload.sub, username: payload.username, did: payload.did, exp: payload.exp, }); } catch { return c.json({ valid: false, error: 'Invalid token' }, 401); } }); app.post('/api/session/verify', async (c) => { const { token } = await c.req.json(); if (!token) { return c.json({ valid: false, error: 'No token' }, 400); } try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return c.json({ valid: true, claims: payload, userId: payload.sub, username: payload.username, did: payload.did, exp: payload.exp, }); } catch { return c.json({ valid: false, error: 'Invalid token' }); } }); /** * Refresh session token */ app.post('/api/session/refresh', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'No token' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh // Issue new token const newToken = await generateSessionToken( payload.sub as string, payload.username as string ); return c.json({ token: newToken }); } catch { return c.json({ error: 'Invalid token' }, 401); } }); // ============================================================================ // USER INFO ENDPOINTS // ============================================================================ /** * Get user credentials (for listing passkeys) */ app.get('/api/user/credentials', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } const token = authHeader.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); const userId = payload.sub as string; const creds = await getUserCredentials(userId); const credentialList = creds.map(cred => ({ credentialId: cred.credentialId, createdAt: cred.createdAt, lastUsed: cred.lastUsed, transports: cred.transports, })); return c.json({ credentials: credentialList }); } catch { return c.json({ error: 'Unauthorized' }, 401); } }); // ============================================================================ // USER PROFILE ENDPOINTS // ============================================================================ // GET /api/user/profile — get the authenticated user's profile app.get('/api/user/profile', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const profile = await getUserProfile(claims.sub as string); if (!profile) return c.json({ error: 'User not found' }, 404); return c.json({ success: true, profile }); }); // GET /api/user/spaces — list spaces the user is a member of app.get('/api/user/spaces', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const spaces = await listSpacesForUser(claims.sub); return c.json({ spaces }); }); // GET /api/user/claims — list pending fund claims for the authenticated user app.get('/api/user/claims', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const profile = await getUserProfile(claims.sub); if (!profile?.profileEmail) return c.json({ claims: [] }); const emailHashed = await hashEmail(profile.profileEmail); const pendingClaims = await getFundClaimsByEmailHash(emailHashed); return c.json({ claims: pendingClaims.map(cl => ({ id: cl.id, token: cl.token, fiatAmount: cl.fiatAmount, fiatCurrency: cl.fiatCurrency, walletAddress: cl.walletAddress, expiresAt: cl.expiresAt, status: cl.status, })), }); }); // PUT /api/user/profile — update the authenticated user's profile app.put('/api/user/profile', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const body = await c.req.json(); const updates: Record = {}; if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.bio !== undefined) { if (typeof body.bio === 'string' && body.bio.length > 500) { return c.json({ error: 'Bio must be 500 characters or fewer' }, 400); } updates.bio = body.bio; } if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl; if (body.profileEmail !== undefined) { if (body.profileEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.profileEmail)) { return c.json({ error: 'Invalid email format' }, 400); } updates.profileEmail = body.profileEmail; } if (body.profileEmailIsRecovery !== undefined) updates.profileEmailIsRecovery = body.profileEmailIsRecovery; if (body.walletAddress !== undefined) updates.walletAddress = body.walletAddress; const profile = await updateUserProfile(claims.sub as string, updates); if (!profile) return c.json({ error: 'User not found' }, 404); // If profile email changed and forwarding is active, update/disable the alias if (updates.profileEmail !== undefined && isMailcowConfigured()) { try { const fwdStatus = await getEmailForwardStatus(claims.sub as string); if (fwdStatus?.enabled && fwdStatus.mailcowId) { if (updates.profileEmail) { // Email changed — update alias destination await updateAlias(fwdStatus.mailcowId, updates.profileEmail); } else { // Email cleared — disable forwarding await deleteAlias(fwdStatus.mailcowId); await setEmailForward(claims.sub as string, false, null); } } } catch (err) { console.error('EncryptID: Failed to update Mailcow alias after profile email change:', err); } } return c.json({ success: true, profile }); }); // ============================================================================ // ENCRYPTED ADDRESS ENDPOINTS // ============================================================================ const MAX_ADDRESSES = 10; const VALID_LABELS = ['home', 'work', 'shipping', 'billing', 'other']; // GET /api/user/addresses — get all encrypted addresses for the user app.get('/api/user/addresses', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const addresses = await getUserAddresses(claims.sub as string); return c.json({ success: true, addresses: addresses.map(a => ({ id: a.id, ciphertext: a.ciphertext, iv: a.iv, label: a.label, labelCustom: a.labelCustom, isDefault: a.isDefault, })), }); }); // POST /api/user/addresses — save a new encrypted address app.post('/api/user/addresses', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const userId = claims.sub as string; const body = await c.req.json(); if (!body.id || !body.ciphertext || !body.iv || !body.label) { return c.json({ error: 'Missing required fields: id, ciphertext, iv, label' }, 400); } if (!VALID_LABELS.includes(body.label)) { return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); } // Check address limit const existing = await getUserAddresses(userId); if (existing.length >= MAX_ADDRESSES) { return c.json({ error: `Maximum ${MAX_ADDRESSES} addresses allowed` }, 400); } const address = await saveUserAddress(userId, { id: body.id, ciphertext: body.ciphertext, iv: body.iv, label: body.label, labelCustom: body.labelCustom, isDefault: body.isDefault || false, }); return c.json({ success: true, address: { id: address.id, ciphertext: address.ciphertext, iv: address.iv, label: address.label, labelCustom: address.labelCustom, isDefault: address.isDefault, }, }, 201); }); // PUT /api/user/addresses/:id — update an existing encrypted address app.put('/api/user/addresses/:id', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const userId = claims.sub as string; const id = c.req.param('id'); const body = await c.req.json(); const existing = await getAddressById(id, userId); if (!existing) return c.json({ error: 'Address not found' }, 404); if (body.label && !VALID_LABELS.includes(body.label)) { return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400); } const address = await saveUserAddress(userId, { id, ciphertext: body.ciphertext || existing.ciphertext, iv: body.iv || existing.iv, label: body.label || existing.label, labelCustom: body.labelCustom !== undefined ? body.labelCustom : existing.labelCustom, isDefault: body.isDefault !== undefined ? body.isDefault : existing.isDefault, }); return c.json({ success: true, address: { id: address.id, ciphertext: address.ciphertext, iv: address.iv, label: address.label, labelCustom: address.labelCustom, isDefault: address.isDefault, }, }); }); // DELETE /api/user/addresses/:id — delete an encrypted address app.delete('/api/user/addresses/:id', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const userId = claims.sub as string; const id = c.req.param('id'); const deleted = await deleteUserAddress(id, userId); if (!deleted) return c.json({ error: 'Address not found' }, 404); return c.json({ success: true }); }); // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================ /** * GET /api/account/status — account setup completion status * Returns which security/setup steps are done. */ app.get('/api/account/status', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const userId = claims.sub as string; // Check email const profile = await getUserProfile(userId); const hasEmail = !!(profile?.profileEmail); // Check credentials count (>1 means multi-device) const creds = await getUserCredentials(userId); const hasMultiDevice = creds.length > 1; // Check guardians let guardianCount = 0; try { const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`; guardianCount = parseInt(rows[0]?.count || '0'); } catch { /* ignore */ } const hasRecovery = guardianCount >= 2; // Check encrypted backup // (This is client-side localStorage, but we can infer from whether they have any synced docs) // For now, we just return the server-side info and let the client check localStorage return c.json({ email: hasEmail, multiDevice: hasMultiDevice, socialRecovery: hasRecovery, credentialCount: creds.length, guardianCount, }); }); /** * POST /api/account/email/start — send verification code to email * Body: { email } * Auth required */ app.post('/api/account/email/start', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { email } = await c.req.json(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return c.json({ error: 'Valid email required' }, 400); } const code = String(Math.floor(100000 + Math.random() * 900000)); const tokenKey = `emailverify_${claims.sub}_${code}`; await storeRecoveryToken({ token: tokenKey, userId: claims.sub as string, type: 'email_verify', createdAt: Date.now(), expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes used: false, }); if (smtpTransport) { try { await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: email, subject: `${code} — rStack Email Verification`, text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`, html: `

Email Verification

Your verification code is:

${code}

This code expires in 10 minutes.

`, }); } catch (err) { console.error('EncryptID: Failed to send verification email:', err); return c.json({ error: 'Failed to send verification email' }, 500); } } else { console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`); } return c.json({ success: true }); }); /** * POST /api/account/email/verify — verify code and set email * Body: { email, code } * Auth required */ app.post('/api/account/email/verify', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { email, code } = await c.req.json(); if (!email || !code) return c.json({ error: 'Email and code required' }, 400); const tokenKey = `emailverify_${claims.sub}_${code}`; const rt = await getRecoveryToken(tokenKey); if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) { return c.json({ error: 'Invalid or expired verification code' }, 400); } await markRecoveryTokenUsed(tokenKey); await setUserEmail(claims.sub as string, email); return c.json({ success: true, email }); }); // ============================================================================ // EMAIL FORWARDING (Mailcow alias management) // ============================================================================ /** * GET /api/account/email-forward — check forwarding status * Auth required */ app.get('/api/account/email-forward', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const status = await getEmailForwardStatus(claims.sub as string); if (!status) return c.json({ error: 'User not found' }, 404); const available = isMailcowConfigured(); const address = `${status.username}@rspace.online`; return c.json({ enabled: status.enabled, address: status.enabled ? address : null, forwardsTo: status.enabled ? status.profileEmail : null, available, }); }); /** * POST /api/account/email-forward/enable — create forwarding alias * Auth required. Requires a verified profile_email. */ app.post('/api/account/email-forward/enable', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); if (!isMailcowConfigured()) { return c.json({ error: 'Email forwarding is not configured' }, 503); } const userId = claims.sub as string; const status = await getEmailForwardStatus(userId); if (!status) return c.json({ error: 'User not found' }, 404); if (status.enabled) { return c.json({ error: 'Email forwarding is already enabled' }, 409); } if (!status.profileEmail) { return c.json({ error: 'A verified profile email is required to enable forwarding' }, 400); } const address = `${status.username}@rspace.online`; // Check for existing alias conflict try { if (await aliasExists(address)) { return c.json({ error: 'An alias already exists for this address' }, 409); } } catch (err) { console.error('EncryptID: Mailcow aliasExists check failed:', err); return c.json({ error: 'Email forwarding service unavailable' }, 503); } try { const mailcowId = await createAlias(status.username, status.profileEmail); await setEmailForward(userId, true, mailcowId); return c.json({ success: true, enabled: true, address, forwardsTo: status.profileEmail, }); } catch (err) { console.error('EncryptID: Failed to create Mailcow alias:', err); return c.json({ error: 'Email forwarding service unavailable' }, 503); } }); /** * POST /api/account/email-forward/disable — remove forwarding alias * Auth required. */ app.post('/api/account/email-forward/disable', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); if (!isMailcowConfigured()) { return c.json({ error: 'Email forwarding is not configured' }, 503); } const userId = claims.sub as string; const status = await getEmailForwardStatus(userId); if (!status) return c.json({ error: 'User not found' }, 404); if (!status.enabled) { return c.json({ error: 'Email forwarding is not enabled' }, 400); } // Best-effort: clear local state even if Mailcow delete fails if (status.mailcowId) { try { await deleteAlias(status.mailcowId); } catch (err) { console.error('EncryptID: Failed to delete Mailcow alias (clearing local state anyway):', err); } } await setEmailForward(userId, false, null); return c.json({ success: true, enabled: false }); }); /** * POST /api/account/device/start — get WebAuthn options for registering another passkey * Auth required */ app.post('/api/account/device/start', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const user = await getUserById(claims.sub as string); if (!user) return c.json({ error: 'User not found' }, 404); const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); await storeChallenge({ challenge, userId: claims.sub as string, type: 'device_registration', createdAt: Date.now(), expiresAt: Date.now() + 5 * 60 * 1000, }); const rpId = resolveRpId(c); const options = { challenge, rp: { id: rpId, name: CONFIG.rpName }, user: { id: claims.sub as string, name: user.username, displayName: user.username, }, pubKeyCredParams: [ { alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }, ], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required', }, timeout: 60000, attestation: 'none', }; return c.json({ options, userId: claims.sub }); }); /** * POST /api/account/device/complete — register additional passkey for existing account * Body: { challenge, credential: { credentialId, publicKey, transports } } * Auth required */ app.post('/api/account/device/complete', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { challenge, credential } = await c.req.json(); if (!challenge || !credential?.credentialId) { return c.json({ error: 'Challenge and credential required' }, 400); } const challengeRecord = await getChallenge(challenge); if (!challengeRecord || challengeRecord.type !== 'device_registration') { return c.json({ error: 'Invalid challenge' }, 400); } if (challengeRecord.userId !== (claims.sub as string)) { return c.json({ error: 'Challenge mismatch' }, 400); } if (Date.now() > challengeRecord.expiresAt) { await deleteChallenge(challenge); return c.json({ error: 'Challenge expired' }, 400); } await deleteChallenge(challenge); const user = await getUserById(claims.sub as string); if (!user) return c.json({ error: 'User not found' }, 404); const rpId = resolveRpId(c); await storeCredential({ credentialId: credential.credentialId, publicKey: credential.publicKey || '', userId: claims.sub as string, username: user.username, counter: 0, createdAt: Date.now(), transports: credential.transports || [], rpId, }); console.log('EncryptID: Additional device registered for', user.username); return c.json({ success: true }); }); // ============================================================================ // RECOVERY ENDPOINTS // ============================================================================ /** * Set recovery email for authenticated user */ app.post('/api/recovery/email/set', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } try { const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); const { email } = await c.req.json(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return c.json({ error: 'Valid email required' }, 400); } await setUserEmail(payload.sub as string, email); return c.json({ success: true, email }); } catch { return c.json({ error: 'Unauthorized' }, 401); } }); /** * Request account recovery via email — sends a recovery token */ app.post('/api/recovery/email/request', async (c) => { const { email } = await c.req.json(); if (!email) { return c.json({ error: 'Email required' }, 400); } const user = await getUserByEmail(email); // Always return success to avoid email enumeration if (!user) { return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' }); } // Generate recovery token const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); const rt: StoredRecoveryToken = { token, userId: user.id, type: 'account_recovery', createdAt: Date.now(), expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes used: false, }; await storeRecoveryToken(rt); // Send recovery email via Mailcow SMTP try { await sendRecoveryEmail(email, token, user.username); } catch (err) { console.error('EncryptID: Failed to send recovery email:', err); } return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' }); }); /** * Verify recovery token — returns temporary auth to register a new passkey */ app.post('/api/recovery/email/verify', async (c) => { const { token: recoveryToken } = await c.req.json(); if (!recoveryToken) { return c.json({ error: 'Recovery token required' }, 400); } const rt = await getRecoveryToken(recoveryToken); if (!rt || rt.used || Date.now() > rt.expiresAt) { return c.json({ error: 'Invalid or expired recovery token' }, 400); } await markRecoveryTokenUsed(recoveryToken); // Get user info const user = await getUserById(rt.userId); if (!user) { return c.json({ error: 'Account not found' }, 404); } // Issue a short-lived recovery session token const sessionToken = await generateSessionToken(rt.userId, user.username); return c.json({ success: true, token: sessionToken, userId: rt.userId, username: user.username, did: user.did, message: 'Recovery successful. Use this token to register a new passkey.', }); }); // ============================================================================ // HELPER FUNCTIONS // ============================================================================ async function generateSessionToken(userId: string, username: string): Promise { const now = Math.floor(Date.now() / 1000); // Check if user has set a wallet address → enable wallet capability // Safe associations are stored client-side (privacy); server only knows // the user has wallet capability via the wallet_address profile field. const profile = await getUserProfile(userId); const hasWallet = !!profile?.walletAddress; const payload = { iss: 'https://auth.rspace.online', sub: userId, aud: CONFIG.allowedOrigins, iat: now, exp: now + CONFIG.sessionDuration, username, did: `did:key:${userId.slice(0, 32)}`, eid: { authLevel: 3, // ELEVATED (fresh WebAuthn) capabilities: { encrypt: true, sign: true, wallet: hasWallet, }, }, }; return sign(payload, CONFIG.jwtSecret); } // ============================================================================ // RECOVERY PAGE // ============================================================================ app.get('/recover', (c) => { return c.html(` rStack Identity — Account Recovery

Account Recovery

Verify your recovery link and add a new passkey to your rStack account

Verifying recovery token...
`); }); // ============================================================================ // GUARDIAN MANAGEMENT ROUTES // ============================================================================ function generateToken(): string { return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); } /** * GET /api/guardians — list my guardians */ app.get('/api/guardians', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const guardians = await getGuardians(claims.sub as string); return c.json({ guardians: guardians.map(g => ({ id: g.id, name: g.name, email: g.email, status: g.status, acceptedAt: g.acceptedAt, createdAt: g.createdAt, })), count: guardians.length, threshold: 2, }); }); /** * POST /api/guardians — add a guardian (max 3 active) * Body: { name, email? } * Returns guardian with invite link/token */ app.post('/api/guardians', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const userId = claims.sub as string; const { name, email } = await c.req.json(); if (!name) return c.json({ error: 'Guardian name is required' }, 400); // Check max 3 active guardians const existing = await getGuardians(userId); if (existing.length >= 3) { return c.json({ error: 'Maximum 3 guardians allowed. Remove one first.' }, 400); } const id = generateToken(); const inviteToken = generateToken(); const inviteExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days const guardian = await addGuardian(id, userId, name, email || null, inviteToken, inviteExpiresAt); const inviteUrl = `https://auth.rspace.online/guardian?token=${inviteToken}`; // Send invite email if email provided if (email && smtpTransport) { const user = await getUserById(userId); const username = user?.username || 'Someone'; try { await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: email, subject: `${username} wants you as a recovery guardian — rStack`, text: [ `Hi ${name},`, '', `${username} has asked you to be a recovery guardian for their rStack Identity account.`, '', 'As a guardian, you can help them recover their account if they lose access to their devices.', 'You will never have access to their account or data — only the ability to approve recovery.', '', 'Accept the invitation:', inviteUrl, '', 'This invitation expires in 7 days.', '', '— rStack Identity', ].join('\n'), html: `
🤝

Guardian Invitation

Hi ${name},

${username} has asked you to be a recovery guardian for their rStack Identity account.

As a guardian, you can help them recover their account if they lose access. You will never have access to their account or data.

Accept Invitation

This invitation expires in 7 days.

Can't click the button? Copy this link:
${inviteUrl}

`, }); } catch (err) { console.error('EncryptID: Failed to send guardian invite email:', err); } } return c.json({ guardian: { id: guardian.id, name: guardian.name, email: guardian.email, status: guardian.status, }, inviteUrl, inviteToken, }, 201); }); /** * DELETE /api/guardians/:id — remove a guardian */ app.delete('/api/guardians/:id', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const { id } = c.req.param(); const removed = await removeGuardian(id, claims.sub as string); if (!removed) return c.json({ error: 'Guardian not found' }, 404); return c.json({ success: true }); }); /** * GET /api/guardian/invites — list accounts I'm a guardian for */ app.get('/api/guardian/invites', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const guardianships = await getGuardianships(claims.sub as string); const result = []; for (const g of guardianships) { const owner = await getUserById(g.userId); result.push({ id: g.id, ownerUsername: owner?.username || 'Unknown', name: g.name, acceptedAt: g.acceptedAt, }); } return c.json({ guardianships: result }); }); /** * GET /api/guardian/requests — pending recovery requests where I'm a guardian */ app.get('/api/guardian/requests', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const guardianships = await getGuardianships(claims.sub as string); const pending = []; for (const g of guardianships) { const req = await getActiveRecoveryRequest(g.userId); if (req) { const owner = await getUserById(g.userId); const approvals = await getRecoveryApprovals(req.id); const myApproval = approvals.find(a => a.guardianId === g.id); pending.push({ requestId: req.id, ownerUsername: owner?.username || 'Unknown', status: req.status, threshold: req.threshold, approvalCount: req.approvalCount, initiatedAt: req.initiatedAt, expiresAt: req.expiresAt, alreadyApproved: !!myApproval?.approvedAt, guardianId: g.id, }); } } return c.json({ requests: pending }); }); // ============================================================================ // GUARDIAN INVITE ACCEPTANCE PAGE // ============================================================================ app.get('/guardian', (c) => { return c.html(` Guardian Invitation — rStack Identity
🤝

Guardian Invitation

Loading invitation...
`); }); /** * GET /api/guardian/accept?token=... — get invite info (unauthenticated) */ app.get('/api/guardian/accept', async (c) => { const token = c.req.query('token'); if (!token) return c.json({ error: 'Token required' }, 400); const guardian = await getGuardianByInviteToken(token); if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404); if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) { return c.json({ error: 'Invitation has expired' }, 400); } if (guardian.status === 'accepted') { return c.json({ error: 'Invitation already accepted' }, 400); } const owner = await getUserById(guardian.userId); return c.json({ guardianName: guardian.name, ownerUsername: owner?.username || 'Unknown', }); }); /** * POST /api/guardian/accept — accept an invite (authenticated) * Body: { inviteToken } */ app.post('/api/guardian/accept', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Sign in to accept this invitation' }, 401); const { inviteToken } = await c.req.json(); if (!inviteToken) return c.json({ error: 'Invite token required' }, 400); const guardian = await getGuardianByInviteToken(inviteToken); if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404); if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) { return c.json({ error: 'Invitation has expired' }, 400); } if (guardian.status === 'accepted') { return c.json({ error: 'Already accepted' }, 400); } // Can't be your own guardian if (guardian.userId === claims.sub) { return c.json({ error: 'You cannot be your own guardian' }, 400); } await acceptGuardianInvite(guardian.id, claims.sub as string); // Notify the account owner that their guardian accepted const acceptorUser = await getUserById(claims.sub as string); notify({ userDid: guardian.userId, category: 'system', eventType: 'guardian_accepted', title: `${acceptorUser?.username || guardian.name} accepted your guardian invite`, body: `${guardian.name} is now a recovery guardian for your account.`, actorDid: claims.sub as string, actorUsername: acceptorUser?.username || guardian.name, metadata: { guardianId: guardian.id }, }).catch(() => {}); return c.json({ success: true, message: 'You are now a guardian!' }); }); // ============================================================================ // SOCIAL RECOVERY ROUTES // ============================================================================ /** * POST /api/recovery/social/initiate — start social recovery * Body: { email or username } * Sends approval links to all guardians */ app.post('/api/recovery/social/initiate', async (c) => { const { email, username: reqUsername } = await c.req.json(); // Find the user let user; if (email) user = await getUserByEmail(email); if (!user && reqUsername) user = await getUserByUsername(reqUsername); // Always return success to prevent enumeration if (!user) return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); const guardians = await getGuardians(user.id); const accepted = guardians.filter(g => g.status === 'accepted'); if (accepted.length < 2) { return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); } // Check for existing active request const existing = await getActiveRecoveryRequest(user.id); if (existing) { return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' }); } // Create recovery request (7 day expiry, 2-of-3 threshold) const requestId = generateToken(); const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; await createRecoveryRequest(requestId, user.id, 2, expiresAt); // Notify account owner about the recovery initiation notify({ userDid: user.id, category: 'system', eventType: 'recovery_initiated', title: 'Account recovery initiated', body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`, metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length }, }).catch(() => {}); // Create approval tokens and notify guardians for (const guardian of accepted) { const approvalToken = generateToken(); await createRecoveryApproval(requestId, guardian.id, approvalToken); const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`; // In-app notification for guardians with rSpace accounts if (guardian.guardianUserId) { notify({ userDid: guardian.guardianUserId, category: 'system', eventType: 'recovery_initiated', title: `${user.username} needs your help to recover their account`, body: 'Review and approve the recovery request if legitimate.', actionUrl: approveUrl, actorUsername: user.username, metadata: { recoveryRequestId: requestId, guardianId: guardian.id }, }).catch(() => {}); } // Send email if available if (guardian.email && smtpTransport) { try { await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: guardian.email, subject: `Recovery request from ${user.username} — rStack`, text: [ `Hi ${guardian.name},`, '', `${user.username} is trying to recover their rStack Identity account and needs your help.`, '', 'To approve this recovery, click the link below:', approveUrl, '', 'This request expires in 7 days. 2 of 3 guardians must approve.', '', 'If you did not expect this, do NOT click the link.', '', '— rStack Identity', ].join('\n'), html: `
🔐

Recovery Request

Hi ${guardian.name},

${user.username} is trying to recover their rStack Identity account and needs your approval.

2 of 3 guardians must approve for recovery to proceed.

Approve Recovery

This request expires in 7 days.

If you did not expect this request, do NOT click the button.

Can't click the button? Copy this link:
${approveUrl}

`, }); } catch (err) { console.error(`EncryptID: Failed to send recovery approval email to ${guardian.email}:`, err); } } } return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); }); /** * GET /approve — one-click guardian approval page */ app.get('/approve', (c) => { return c.html(` Approve Recovery — rStack Identity
🔐

Recovery Approval

Verifying approval link...
`); }); /** * POST /api/recovery/social/approve — approve via token (no auth needed, token is proof) */ app.post('/api/recovery/social/approve', async (c) => { const { approvalToken } = await c.req.json(); if (!approvalToken) return c.json({ error: 'Approval token required' }, 400); const result = await approveRecoveryByToken(approvalToken); if (!result) return c.json({ error: 'Invalid or already used approval link' }, 400); // Get updated request const request = await getRecoveryRequest(result.requestId); if (!request) return c.json({ error: 'Recovery request not found' }, 404); // Check if threshold met if (request.approvalCount >= request.threshold && request.status === 'pending') { await updateRecoveryRequestStatus(request.id, 'approved'); // Notify account owner that recovery is approved notify({ userDid: request.userId, category: 'system', eventType: 'recovery_approved', title: 'Account recovery approved', body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`, metadata: { recoveryRequestId: request.id }, }).catch(() => {}); } return c.json({ success: true, approvalCount: request.approvalCount, threshold: request.threshold, status: request.approvalCount >= request.threshold ? 'approved' : 'pending', }); }); /** * GET /api/recovery/social/:id/status — check recovery request status */ app.get('/api/recovery/social/:id/status', async (c) => { const { id } = c.req.param(); const request = await getRecoveryRequest(id); if (!request) return c.json({ error: 'Not found' }, 404); const approvals = await getRecoveryApprovals(request.id); return c.json({ id: request.id, status: request.status, threshold: request.threshold, approvalCount: request.approvalCount, approvals: approvals.map(a => ({ guardianId: a.guardianId, approved: !!a.approvedAt, })), expiresAt: request.expiresAt, }); }); /** * POST /api/recovery/social/:id/complete — finalize recovery (register new passkey) * Only works when status is 'approved' */ app.post('/api/recovery/social/:id/complete', async (c) => { const { id } = c.req.param(); const request = await getRecoveryRequest(id); if (!request) return c.json({ error: 'Not found' }, 404); if (request.status !== 'approved') return c.json({ error: 'Recovery not yet approved. ' + request.approvalCount + '/' + request.threshold + ' approvals.' }, 400); if (Date.now() > request.expiresAt) { await updateRecoveryRequestStatus(id, 'expired'); return c.json({ error: 'Recovery request expired' }, 400); } // Mark completed and issue a recovery session token await updateRecoveryRequestStatus(id, 'completed'); const user = await getUserById(request.userId); if (!user) return c.json({ error: 'User not found' }, 404); const token = await generateSessionToken(request.userId, user.username); return c.json({ success: true, token, userId: request.userId, username: user.username, message: 'Recovery complete. Use this token to register a new passkey.', }); }); // ============================================================================ // DEVICE LINKING ROUTES // ============================================================================ /** * POST /api/device-link/start — generate a device link token (authenticated) */ app.post('/api/device-link/start', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Unauthorized' }, 401); const token = generateToken(); const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes await createDeviceLink(token, claims.sub as string, expiresAt); const linkUrl = `https://auth.rspace.online/link?token=${token}`; return c.json({ token, linkUrl, expiresAt }); }); /** * GET /api/device-link/:token/info — get link info (unauthenticated) */ app.get('/api/device-link/:token/info', async (c) => { const { token } = c.req.param(); const link = await getDeviceLink(token); if (!link) return c.json({ error: 'Invalid link' }, 404); if (link.used) return c.json({ error: 'Link already used' }, 400); if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400); const user = await getUserById(link.userId); return c.json({ username: user?.username || 'Unknown', expiresAt: link.expiresAt, }); }); /** * POST /api/device-link/:token/complete — register new credential on linked device */ app.post('/api/device-link/:token/complete', async (c) => { const { token } = c.req.param(); const link = await getDeviceLink(token); if (!link) return c.json({ error: 'Invalid link' }, 404); if (link.used) return c.json({ error: 'Link already used' }, 400); if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400); const { credential } = await c.req.json(); if (!credential?.credentialId || !credential?.publicKey) { return c.json({ error: 'Credential data required' }, 400); } const user = await getUserById(link.userId); if (!user) return c.json({ error: 'User not found' }, 404); // Store the new credential under the same user const rpId = resolveRpId(c); await storeCredential({ credentialId: credential.credentialId, publicKey: credential.publicKey, userId: link.userId, username: user.username, counter: 0, createdAt: Date.now(), transports: credential.transports, rpId, }); await markDeviceLinkUsed(token); console.log('EncryptID: Second device linked for', user.username); return c.json({ success: true, message: 'Device linked successfully' }); }); /** * GET /link — device linking page (scanned from QR or opened from email) */ app.get('/link', (c) => { return c.html(` Link Device — rStack Identity
📱

Link This Device

Loading...
`); }); // ============================================================================ // SHARED AUTH HELPER // ============================================================================ // Helper: verify JWT and return claims, or null async function verifyTokenFromRequest(authorization: string | undefined): Promise<{ sub: string; did?: string; username?: string; eid?: any; } | null> { if (!authorization?.startsWith('Bearer ')) return null; const token = authorization.slice(7); try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); return payload as { sub: string; did?: string; username?: string; eid?: any }; } catch { return null; } } // ============================================================================ // WALLET CAPABILITY ROUTES // ============================================================================ // POST /encryptid/api/wallet-capability — Enable wallet capability for this user. // Sets wallet_address on the user profile. Safe associations are stored // client-side only (encrypted localStorage) to avoid server-side correlation // between identity and on-chain addresses. app.post('/encryptid/api/wallet-capability', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const { walletAddress } = await c.req.json(); if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) { return c.json({ error: 'Valid Ethereum address required' }, 400); } await updateUserProfile(claims.sub, { walletAddress }); return c.json({ enabled: true, walletAddress }); }); // DELETE /encryptid/api/wallet-capability — Disable wallet capability. app.delete('/encryptid/api/wallet-capability', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); await updateUserProfile(claims.sub, { walletAddress: null }); return c.json({ enabled: false }); }); // POST /encryptid/api/safe/verify — Check if EOA is owner on a Safe (stateless proxy). // No server-side state — just proxies Safe Transaction Service API. app.post('/encryptid/api/safe/verify', async (c) => { const { safeAddress, chainId, eoaAddress } = await c.req.json(); if (!safeAddress || !eoaAddress) { return c.json({ error: 'safeAddress and eoaAddress are required' }, 400); } const chain = chainId || 84532; const CHAIN_PREFIXES: Record = { 1: 'eth', 10: 'oeth', 100: 'gno', 137: 'pol', 8453: 'base', 42161: 'arb1', 42220: 'celo', 43114: 'avax', 56: 'bnb', 324: 'zksync', 11155111: 'sep', 84532: 'basesep', }; const prefix = CHAIN_PREFIXES[chain]; if (!prefix) { return c.json({ error: 'Unsupported chain' }, 400); } try { const safeRes = await fetch( `https://api.safe.global/tx-service/${prefix}/api/v1/safes/${safeAddress}/`, ); if (!safeRes.ok) { return c.json({ isOwner: false, error: 'Safe not found' }); } const safeData = await safeRes.json() as { owners?: string[]; threshold?: number; nonce?: number }; const owners = (safeData.owners || []).map((o: string) => o.toLowerCase()); const isOwner = owners.includes(eoaAddress.toLowerCase()); return c.json({ isOwner, safeAddress, chainId: chain, threshold: safeData.threshold, ownerCount: owners.length, nonce: safeData.nonce, }); } catch { return c.json({ isOwner: false, error: 'Failed to query Safe' }, 502); } }); // ============================================================================ // SPACE MEMBERSHIP ROUTES // ============================================================================ const VALID_ROLES = ['viewer', 'member', 'moderator', 'admin']; // GET /api/spaces/:slug/members — list all members app.get('/api/spaces/:slug/members', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } const members = await listSpaceMembers(slug); return c.json({ members: members.map((m) => ({ userDID: m.userDID, spaceSlug: m.spaceSlug, role: m.role, joinedAt: m.joinedAt, grantedBy: m.grantedBy, })), total: members.length, }); }); // GET /api/spaces/:slug/members/:did — get one member's role app.get('/api/spaces/:slug/members/:did', async (c) => { const { slug, did } = c.req.param(); const member = await getSpaceMember(slug, decodeURIComponent(did)); if (!member) { return c.json({ error: 'Member not found' }, 404); } return c.json({ userDID: member.userDID, spaceSlug: member.spaceSlug, role: member.role, joinedAt: member.joinedAt, grantedBy: member.grantedBy, }); }); // POST /api/spaces/:slug/members — add or update a member app.post('/api/spaces/:slug/members', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } // Verify caller is admin in this space (or first member → becomes admin) const callerMember = await getSpaceMember(slug, claims.sub); const members = await listSpaceMembers(slug); // If space has no members, the first person to add becomes admin const isFirstMember = members.length === 0; if (!isFirstMember && (!callerMember || callerMember.role !== 'admin')) { return c.json({ error: 'Admin role required' }, 403); } const body = await c.req.json<{ userDID: string; role: string }>(); if (!body.userDID || !body.role) { return c.json({ error: 'userDID and role are required' }, 400); } if (!VALID_ROLES.includes(body.role)) { return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); } const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub); return c.json({ userDID: member.userDID, spaceSlug: member.spaceSlug, role: member.role, joinedAt: member.joinedAt, grantedBy: member.grantedBy, }, 201); }); // DELETE /api/spaces/:slug/members/:did — remove a member app.delete('/api/spaces/:slug/members/:did', async (c) => { const { slug, did } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) { return c.json({ error: 'Authentication required' }, 401); } const callerMember = await getSpaceMember(slug, claims.sub); if (!callerMember || callerMember.role !== 'admin') { return c.json({ error: 'Admin role required' }, 403); } const removed = await removeSpaceMember(slug, decodeURIComponent(did)); if (!removed) { return c.json({ error: 'Member not found' }, 404); } return c.json({ success: true }); }); // ============================================================================ // USER LOOKUP // ============================================================================ // GET /api/users/lookup?username= — look up user by username app.get('/api/users/lookup', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const username = c.req.query('username'); if (!username) return c.json({ error: 'username query parameter required' }, 400); const user = await getUserByUsername(username); if (!user) return c.json({ error: 'User not found' }, 404); return c.json({ did: user.did, username: user.username, displayName: user.display_name || user.username, }); }); // ============================================================================ // SPACE INVITE ROUTES // ============================================================================ // POST /api/spaces/:slug/invites — create an invite app.post('/api/spaces/:slug/invites', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); // Require admin role const callerMember = await getSpaceMember(slug, claims.sub); if (!callerMember || callerMember.role !== 'admin') { return c.json({ error: 'Admin role required' }, 403); } const body = await c.req.json<{ email?: string; role?: string }>(); const role = body.role || 'member'; if (!VALID_ROLES.includes(role)) { return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); } const id = crypto.randomUUID(); const token = crypto.randomUUID(); const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days const invite = await createSpaceInvite(id, slug, token, claims.sub, role, expiresAt, body.email || undefined); return c.json(invite, 201); }); // GET /api/spaces/:slug/invites — list invites for a space app.get('/api/spaces/:slug/invites', async (c) => { const { slug } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const callerMember = await getSpaceMember(slug, claims.sub); if (!callerMember || callerMember.role !== 'admin') { return c.json({ error: 'Admin role required' }, 403); } const invites = await listSpaceInvites(slug); return c.json({ invites, total: invites.length }); }); // POST /api/spaces/:slug/invites/:id/revoke — revoke an invite app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => { const { slug, id } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const callerMember = await getSpaceMember(slug, claims.sub); if (!callerMember || callerMember.role !== 'admin') { return c.json({ error: 'Admin role required' }, 403); } const revoked = await revokeSpaceInvite(id, slug); if (!revoked) return c.json({ error: 'Invite not found or already used' }, 404); return c.json({ ok: true }); }); // GET /api/invites/:token — get invite details by token (public, no auth needed) app.get('/api/invites/:token', async (c) => { const { token } = c.req.param(); const invite = await getSpaceInviteByToken(token); if (!invite) return c.json({ error: 'Invite not found' }, 404); if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410); if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410); return c.json({ spaceSlug: invite.spaceSlug, role: invite.role, expiresAt: invite.expiresAt, }); }); // POST /api/invites/:token/accept — accept an invite app.post('/api/invites/:token/accept', async (c) => { const { token } = c.req.param(); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required — sign in to accept' }, 401); const invite = await getSpaceInviteByToken(token); if (!invite) return c.json({ error: 'Invite not found' }, 404); if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410); if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410); // Accept the invite const accepted = await acceptSpaceInvite(token, claims.sub); if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500); // Add to space_members with the invite's role await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy); return c.json({ ok: true, spaceSlug: accepted.spaceSlug, role: accepted.role, }); }); // ============================================================================ // ADMIN ROUTES // ============================================================================ function isAdmin(did: string | undefined): boolean { if (!did || CONFIG.adminDIDs.length === 0) return false; return CONFIG.adminDIDs.includes(did); } // GET /api/admin/users — list all users (admin only) app.get('/api/admin/users', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); const users = await listAllUsers(); return c.json({ users, total: users.length }); }); // DELETE /api/admin/users/:userId — delete a user (admin only) app.delete('/api/admin/users/:userId', async (c) => { const userId = c.req.param('userId'); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); if (userId === claims.sub) { return c.json({ error: 'Cannot delete your own account' }, 400); } const deleted = await deleteUser(userId); if (!deleted) return c.json({ error: 'User not found' }, 404); return c.json({ ok: true, message: `User ${userId} deleted` }); }); // DELETE /api/admin/spaces/:slug/members — clean up space members (admin only) app.delete('/api/admin/spaces/:slug/members', async (c) => { const slug = c.req.param('slug'); const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403); const count = await deleteSpaceMembers(slug); return c.json({ ok: true, removed: count }); }); // ============================================================================ // FUND CLAIM ROUTES // ============================================================================ const INTERNAL_SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || ''; // POST /api/internal/fund-claims — called by rflows to create a claim app.post('/api/internal/fund-claims', async (c) => { const serviceKey = c.req.header('X-Service-Key'); if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) { return c.json({ error: 'Unauthorized' }, 401); } const { email, walletAddress, openfortPlayerId, fiatAmount, fiatCurrency, sessionId, provider } = await c.req.json(); if (!email || !walletAddress) { return c.json({ error: 'email and walletAddress are required' }, 400); } const emailHashed = await hashEmail(email); const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days // Check for existing pending claim — accumulate deposits const existingClaims = await getFundClaimsByEmailHash(emailHashed); if (existingClaims.length > 0 && fiatAmount) { const existing = existingClaims[0]; const updated = await accumulateFundClaim(existing.id, fiatAmount, expiresAt); let sent = false; try { const totalAmount = updated?.fiatAmount || fiatAmount; sent = await sendClaimEmail(email, existing.token, totalAmount, fiatCurrency || 'USD'); } catch (err) { console.error('EncryptID: Failed to send claim email:', err); } return c.json({ claimId: existing.id, accumulated: true, sent }); } // No existing claim — create new const id = crypto.randomUUID(); const token = generateToken(); const claim = await createFundClaim({ id, token, emailHash: emailHashed, email, walletAddress, openfortPlayerId, fiatAmount, fiatCurrency: fiatCurrency || 'USD', sessionId, provider, expiresAt, }); let sent = false; try { sent = await sendClaimEmail(email, token, fiatAmount, fiatCurrency || 'USD'); } catch (err) { console.error('EncryptID: Failed to send claim email:', err); } return c.json({ claimId: claim.id, sent }); }); // GET /api/claims/:token — public claim info (no auth) app.get('/api/claims/:token', async (c) => { const token = c.req.param('token'); const claim = await getFundClaimByToken(token); if (!claim) return c.json({ error: 'Claim not found' }, 404); const truncatedWallet = `${claim.walletAddress.slice(0, 6)}...${claim.walletAddress.slice(-4)}`; return c.json({ status: claim.status, amount: claim.fiatAmount, currency: claim.fiatCurrency, truncatedWallet, expiresAt: claim.expiresAt, expired: claim.expiresAt < Date.now(), }); }); // POST /api/claims/:token/accept — authenticated claim acceptance app.post('/api/claims/:token/accept', async (c) => { const claims = await verifyTokenFromRequest(c.req.header('Authorization')); if (!claims) return c.json({ error: 'Authentication required' }, 401); const token = c.req.param('token'); const fundClaim = await getFundClaimByToken(token); if (!fundClaim) return c.json({ error: 'Claim not found' }, 404); if (fundClaim.status !== 'pending' && fundClaim.status !== 'resent') return c.json({ error: 'Claim already processed' }, 400); if (fundClaim.expiresAt < Date.now()) return c.json({ error: 'Claim expired' }, 400); // Link wallet to user profile await updateUserProfile(claims.sub, { walletAddress: fundClaim.walletAddress }); // Mark claim as claimed const accepted = await acceptFundClaim(token, claims.sub); if (!accepted) return c.json({ error: 'Failed to accept claim' }, 500); // Get username for redirect URL const user = await getUserById(claims.sub); return c.json({ success: true, walletAddress: fundClaim.walletAddress, amount: fundClaim.fiatAmount, currency: fundClaim.fiatCurrency, username: user?.username || '', }); }); // POST /api/claims/resend — resend claim email (anti-enumeration: always 200) app.post('/api/claims/resend', async (c) => { const { email } = await c.req.json(); if (!email) return c.json({ ok: true }); const emailHashed = await hashEmail(email); const pendingClaims = await getFundClaimsByEmailHash(emailHashed); if (pendingClaims.length > 0) { const claim = pendingClaims[0]; // Generate new token for the resend const newToken = generateToken(); await sql`UPDATE fund_claims SET token = ${newToken}, status = 'resent' WHERE id = ${claim.id}`; try { await sendClaimEmail(email, newToken, claim.fiatAmount || undefined, claim.fiatCurrency); } catch (err) { console.error('EncryptID: Failed to resend claim email:', err); } } // Always return ok (anti-enumeration) return c.json({ ok: true }); }); // GET /claim — self-contained claim page app.get('/claim', (c) => { return c.html(` rStack — Claim Your Funds
💰

Claim Your Funds

Sign in with EncryptID to link your funded wallet

Loading claim...

`); }); // ============================================================================ // IDENTITY INVITES ("Claim your rSpace" flow) // ============================================================================ // Send an invite (authenticated user invites a friend by email) app.post('/api/invites/identity', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } let payload: any; try { payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'Invalid token' }, 401); } const body = await c.req.json(); const { email, message, spaceSlug, spaceRole } = body; if (!email || typeof email !== 'string' || !email.includes('@')) { return c.json({ error: 'Valid email is required' }, 400); } // Check if this email already has an account const existingUser = await getUserByEmail(email.toLowerCase().trim()); if (existingUser) { return c.json({ error: 'This person already has an EncryptID account' }, 409); } const id = crypto.randomUUID(); const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url'); const invite = await createIdentityInvite({ id, token, email: email.toLowerCase().trim(), invitedByUserId: payload.sub, invitedByUsername: payload.username, message: message || undefined, spaceSlug: spaceSlug || undefined, spaceRole: spaceRole || 'member', expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days }); // Send invite email const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`; if (smtpTransport) { try { await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: email, subject: `${payload.username} invited you to join rSpace`, html: `

You've been invited to rSpace

${escapeHtml(payload.username)} wants you to join the rSpace ecosystem — a suite of privacy-first tools powered by passkey authentication.

${message ? `
"${escapeHtml(message)}"
` : ''}

Click below to claim your identity and set up your passkey:

Claim your rSpace

This invite expires in 7 days. If you didn't expect this, you can safely ignore it.

`, }); } catch (err) { console.error('EncryptID: Failed to send invite email:', (err as Error).message); } } else { console.log(`EncryptID: [NO SMTP] Invite link for ${email}: ${joinLink}`); } return c.json({ id: invite.id, token: invite.token, email: invite.email }); }); // List invites sent by the authenticated user app.get('/api/invites/identity', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } let payload: any; try { payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'Invalid token' }, 401); } const invites = await getIdentityInvitesByInviter(payload.sub); return c.json({ invites }); }); // Revoke a pending invite app.delete('/api/invites/identity/:id', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } let payload: any; try { payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'Invalid token' }, 401); } const revoked = await revokeIdentityInvite(c.req.param('id'), payload.sub); return revoked ? c.json({ success: true }) : c.json({ error: 'Invite not found or already claimed' }, 404); }); // Get invite info (public — for the claim page) app.get('/api/invites/identity/:token/info', async (c) => { const invite = await getIdentityInviteByToken(c.req.param('token')); if (!invite || invite.status !== 'pending') { return c.json({ error: 'Invite not found or expired' }, 404); } if (Date.now() > invite.expiresAt) { return c.json({ error: 'Invite expired' }, 410); } return c.json({ invitedBy: invite.invitedByUsername, message: invite.message, email: invite.email, spaceSlug: invite.spaceSlug, }); }); // Claim an invite (after passkey registration) app.post('/api/invites/identity/:token/claim', async (c) => { const token = c.req.param('token'); const body = await c.req.json(); const { sessionToken } = body; // Verify the new user's session token (from registration) let payload: any; try { payload = await verify(sessionToken, CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'Invalid session' }, 401); } const invite = await getIdentityInviteByToken(token); if (!invite || invite.status !== 'pending') { return c.json({ error: 'Invite not found or already claimed' }, 404); } if (Date.now() > invite.expiresAt) { return c.json({ error: 'Invite expired' }, 410); } // Link the email to the new user's account await setUserEmail(payload.sub, invite.email); // Claim the invite const claimed = await claimIdentityInvite(token, payload.sub); if (!claimed) { return c.json({ error: 'Failed to claim invite' }, 500); } // Auto-join space if specified if (invite.spaceSlug) { const did = `did:key:${payload.sub.slice(0, 32)}`; await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`); } return c.json({ success: true, email: invite.email, spaceSlug: invite.spaceSlug }); }); // Join page — the invite claim UI app.get('/join', async (c) => { const token = c.req.query('token'); return c.html(joinPage(token || '')); }); function joinPage(token: string): string { return ` Claim your rSpace — EncryptID

Claim your rSpace

Create your passkey-protected identity

  • No passwords — your device is the key
  • Works across the entire r* ecosystem
  • Privacy-first, encrypted by default
`; } // ============================================================================ // OIDC PROVIDER (Authorization Code Flow) // ============================================================================ const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.ridentity.online'; // Discovery document app.get('/.well-known/openid-configuration', (c) => { return c.json({ issuer: OIDC_ISSUER, authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`, token_endpoint: `${OIDC_ISSUER}/oidc/token`, userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`, response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['HS256'], scopes_supported: ['openid', 'profile', 'email'], claims_supported: ['sub', 'email', 'name', 'preferred_username'], grant_types_supported: ['authorization_code'], token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'], }); }); // Authorization endpoint app.get('/oidc/authorize', async (c) => { const clientId = c.req.query('client_id'); const redirectUri = c.req.query('redirect_uri'); const responseType = c.req.query('response_type'); const scope = c.req.query('scope') || 'openid profile email'; const state = c.req.query('state') || ''; if (responseType !== 'code') { return c.text('Unsupported response_type', 400); } if (!clientId || !redirectUri) { return c.text('Missing client_id or redirect_uri', 400); } const client = await getOidcClient(clientId); if (!client) { return c.text('Unknown client_id', 400); } if (!client.redirectUris.includes(redirectUri)) { return c.text('Invalid redirect_uri', 400); } // Serve the authorize page — it handles login + consent in one flow return c.html(oidcAuthorizePage(client.name, clientId, redirectUri, scope, state)); }); // Authorization callback (POST from the authorize page after passkey login) app.post('/oidc/authorize', async (c) => { const body = await c.req.json(); const { clientId, redirectUri, scope, state, token } = body; // Verify the session token from passkey login let payload: any; try { payload = await verify(token, CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'Invalid session' }, 401); } const client = await getOidcClient(clientId); if (!client) { return c.json({ error: 'Unknown client' }, 400); } if (!client.redirectUris.includes(redirectUri)) { return c.json({ error: 'Invalid redirect_uri' }, 400); } // Check email allowlist const user = await getUserById(payload.sub); if (!user) { return c.json({ error: 'User not found' }, 404); } const userEmail = user.email || user.profile_email; if (client.allowedEmails.length > 0) { if (!userEmail || !client.allowedEmails.includes(userEmail)) { return c.json({ error: 'access_denied', message: 'You do not have access to this application.' }, 403); } } // Generate auth code const code = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); await createOidcAuthCode(code, clientId, payload.sub, redirectUri, scope || 'openid profile email'); const url = new URL(redirectUri); url.searchParams.set('code', code); if (state) url.searchParams.set('state', state); return c.json({ redirectUrl: url.toString() }); }); // Token endpoint app.post('/oidc/token', async (c) => { const contentType = c.req.header('content-type') || ''; let grantType: string, code: string, redirectUri: string, clientId: string, clientSecret: string; if (contentType.includes('application/json')) { const body = await c.req.json(); grantType = body.grant_type; code = body.code; redirectUri = body.redirect_uri; clientId = body.client_id; clientSecret = body.client_secret; } else { // application/x-www-form-urlencoded (standard OIDC) const body = await c.req.parseBody(); grantType = body.grant_type as string; code = body.code as string; redirectUri = body.redirect_uri as string; clientId = body.client_id as string; clientSecret = body.client_secret as string; } // Support Basic auth for client credentials if (!clientId || !clientSecret) { const authHeader = c.req.header('Authorization'); if (authHeader?.startsWith('Basic ')) { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString(); const [id, secret] = decoded.split(':'); clientId = clientId || id; clientSecret = clientSecret || secret; } } if (grantType !== 'authorization_code') { return c.json({ error: 'unsupported_grant_type' }, 400); } const client = await getOidcClient(clientId); if (!client) { return c.json({ error: 'invalid_client' }, 401); } // Constant-time comparison for client secret const expected = new TextEncoder().encode(client.clientSecret); const received = new TextEncoder().encode(clientSecret || ''); if (expected.length !== received.length) { return c.json({ error: 'invalid_client' }, 401); } let mismatch = 0; for (let i = 0; i < expected.length; i++) { mismatch |= expected[i] ^ received[i]; } if (mismatch !== 0) { return c.json({ error: 'invalid_client' }, 401); } // Consume auth code (atomic — marks used) const authCode = await consumeOidcAuthCode(code); if (!authCode) { return c.json({ error: 'invalid_grant' }, 400); } if (authCode.clientId !== clientId) { return c.json({ error: 'invalid_grant' }, 400); } if (authCode.redirectUri !== redirectUri) { return c.json({ error: 'invalid_grant' }, 400); } // Look up user for claims const user = await getUserById(authCode.userId); if (!user) { return c.json({ error: 'invalid_grant' }, 400); } const now = Math.floor(Date.now() / 1000); const idTokenPayload = { iss: OIDC_ISSUER, sub: authCode.userId, aud: clientId, iat: now, exp: now + 3600, email: user.email || user.profile_email || '', name: user.display_name || user.username, preferred_username: user.username, }; const idToken = await sign(idTokenPayload, CONFIG.jwtSecret); // Access token is also a JWT with same claims (used by userinfo) const accessTokenPayload = { iss: OIDC_ISSUER, sub: authCode.userId, aud: clientId, iat: now, exp: now + 3600, scope: authCode.scope, }; const accessToken = await sign(accessTokenPayload, CONFIG.jwtSecret); return c.json({ access_token: accessToken, token_type: 'Bearer', expires_in: 3600, id_token: idToken, }); }); // UserInfo endpoint app.get('/oidc/userinfo', async (c) => { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return c.json({ error: 'invalid_token' }, 401); } const token = authHeader.slice(7); let payload: any; try { payload = await verify(token, CONFIG.jwtSecret, 'HS256'); } catch { return c.json({ error: 'invalid_token' }, 401); } const user = await getUserById(payload.sub); if (!user) { return c.json({ error: 'invalid_token' }, 401); } return c.json({ sub: payload.sub, email: user.email || user.profile_email || '', name: user.display_name || user.username, preferred_username: user.username, }); }); // Authorize page HTML — embeds passkey login, then posts back to complete authorization function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string { return ` Sign in — EncryptID

Sign in with EncryptID

${escapeHtml(appName)} wants to access your account

  • Your name and username
  • Your email address

`; } function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ============================================================================ // SERVE STATIC FILES // ============================================================================ // Serve demo page and static assets app.get('/demo.html', serveStatic({ path: './src/encryptid/demo.html' })); // Serve bundled JavaScript modules app.use('/dist/*', serveStatic({ root: './src/encryptid/' })); app.use('/demo/*', serveStatic({ root: './src/encryptid/' })); app.use('/static/*', serveStatic({ root: './public/' })); // Serve index — landing page with real auth app.get('/', (c) => { return c.html(` rStack Identity — One Passkey for the r* Ecosystem

rStack Identity

One passkey for the rStack.online ecosystem

Get started

Sign in with your passkey or create a new account. No passwords, no tracking, no third parties.

?

Loading...

SessionActive
Token expires--

Your Passkeys

Loading...

Account Security

Passkey created
Recovery email
Second device
Guardians (0/3)
Encrypted vault backup

Recovery Email

Recommended for cross-device login and account recovery.

Linked Devices

Add a passkey on another device so you can sign in from anywhere.

Recovery Guardians

Choose 3 people you trust. If you lose all your devices, any 2 of them can help you recover your account.

Encrypted Account Vault

Your account data is encrypted with your passkey and stored securely. Only you can unlock it.

Status: Not synced

My Wallet

Your EncryptID-linked wallet for receiving and managing funds.

No wallet linked

My Spaces

Communities you belong to and their treasury wallets.

Loading...
SDK Demo
Passkey Auth
Hardware-backed, phishing-resistant login. No passwords ever.
Guardian Recovery
3 trusted contacts, 2 to recover. No seed phrases, no single point of failure.
E2E Encryption
Derive keys from your passkey. Keys never leave your device.
Cross-App Identity
One passkey works across every app in the rStack ecosystem.
`); }); // ============================================================================ // DATABASE INITIALIZATION & SERVER START // ============================================================================ // Initialize database on startup with retries (Docker networking may take a moment) (async () => { const maxRetries = 5; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await initDatabase(); break; } catch (err) { console.error(`EncryptID: Database init attempt ${attempt}/${maxRetries} failed:`, (err as Error).message); if (attempt === maxRetries) { console.error('EncryptID: All database init attempts exhausted, exiting'); process.exit(1); } await new Promise(r => setTimeout(r, attempt * 2000)); } } // Seed OIDC clients from environment (after DB is ready) try { const clientsJson = process.env.OIDC_CLIENTS; if (clientsJson) { const clients = JSON.parse(clientsJson); await seedOidcClients(clients); console.log(`EncryptID: Seeded ${clients.length} OIDC client(s)`); } } catch (err) { console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message); } })(); // Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes setInterval(() => { cleanExpiredChallenges().catch(() => {}); cleanExpiredRecoveryTokens().catch(() => {}); cleanExpiredFundClaims().catch(() => {}); cleanExpiredOidcCodes().catch(() => {}); cleanExpiredIdentityInvites().catch(() => {}); }, 10 * 60 * 1000); console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ 🔐 rStack Identity (EncryptID) ║ ║ ║ ║ One passkey for the rStack.online ecosystem ║ ║ ║ ║ Port: ${CONFIG.port} ║ ║ RP ID: ${CONFIG.rpId} ║ ║ Storage: PostgreSQL ║ ║ SMTP: ${CONFIG.smtp.pass ? CONFIG.smtp.host : 'disabled (no SMTP_PASS)'}${' '.repeat(Math.max(0, 36 - (CONFIG.smtp.pass ? CONFIG.smtp.host.length : 26)))}║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ `); export default { port: CONFIG.port, fetch: app.fetch, };