rspace-online/src/encryptid/server.ts

3269 lines
125 KiB
TypeScript

/**
* 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,
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,
upsertSpaceMember,
removeSpaceMember,
getUserProfile,
updateUserProfile,
getUserAddresses,
getAddressById,
saveUserAddress,
deleteUserAddress,
getEmailForwardStatus,
setEmailForward,
listAllUsers,
deleteUser,
deleteSpaceMembers,
sql,
} from './db.js';
import {
isMailcowConfigured,
createAlias,
deleteAlias,
updateAlias,
aliasExists,
} from './mailcow.js';
// ============================================================================
// 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: 15 * 60, // 15 minutes
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 <noreply@rspace.online>',
},
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://rfunds.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://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<boolean> {
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: `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#128274;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Identity</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${username}</strong>,</p>
<p>A recovery request was made for your rStack account. Click below to add a new passkey:</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${recoveryLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Recover Account</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This link expires in <strong>30 minutes</strong>.</p>
<p>If you didn't request this, you can safely ignore this email — your account is secure.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${recoveryLink}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`,
});
return true;
}
// ============================================================================
// 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 <username>.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, 'members_only', {
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 });
});
// 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<string, any> = {};
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
// ============================================================================
/**
* 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_verification',
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: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#9993;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Email Verification</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;text-align:center;">
<p>Your verification code is:</p>
<div style="font-size:36px;font-weight:800;letter-spacing:8px;color:#06b6d4;padding:16px 0;">${code}</div>
<p style="color:#94a3b8;font-size:13px;">This code expires in 10 minutes.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} 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<string> {
const now = Math.floor(Date.now() / 1000);
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: false,
},
},
};
return sign(payload, CONFIG.jwtSecret);
}
// ============================================================================
// RECOVERY PAGE
// ============================================================================
app.get('/recover', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rStack Identity — Account Recovery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 2.5rem;
max-width: 440px;
width: 100%;
text-align: center;
}
.logo { font-size: 3rem; margin-bottom: 0.5rem; }
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn {
display: inline-block; padding: 0.75rem 2rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
color: #fff; border: none; border-radius: 8px;
font-weight: 600; font-size: 1rem; cursor: pointer;
transition: transform 0.2s;
}
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="logo">&#128274;</div>
<h1>Account Recovery</h1>
<p class="subtitle">Verify your recovery link and add a new passkey to your rStack account</p>
<div id="status" class="status loading">Verifying recovery token...</div>
<div id="register-section" class="hidden">
<button id="register-btn" class="btn" onclick="registerNewPasskey()">
Add New Passkey
</button>
</div>
<div id="success-section" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;">You can now sign in with your new passkey on any r*.online app.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const recoveryToken = params.get('token');
let sessionToken = null;
let userId = null;
let username = null;
const statusEl = document.getElementById('status');
const registerSection = document.getElementById('register-section');
const successSection = document.getElementById('success-section');
async function verifyToken() {
if (!recoveryToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No recovery token found in link.';
return;
}
try {
const res = await fetch('/api/recovery/email/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: recoveryToken }),
});
const data = await res.json();
if (!data.success) {
statusEl.className = 'status error';
statusEl.textContent = data.error || 'Invalid or expired recovery link.';
return;
}
sessionToken = data.token;
userId = data.userId;
username = data.username;
statusEl.className = 'status success';
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
registerSection.classList.remove('hidden');
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to verify recovery token.';
}
}
async function registerNewPasskey() {
const btn = document.getElementById('register-btn');
btn.disabled = true;
btn.textContent = 'Registering...';
try {
// Start registration
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
body: JSON.stringify({ username, displayName: username }),
});
const { options } = await startRes.json();
// Decode challenge for WebAuthn
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
// WebAuthn ceremony
const credential = await navigator.credentials.create({ publicKey: options });
const credentialData = {
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
transports: credential.response.getTransports?.() || [],
};
// Complete registration
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
});
const result = await completeRes.json();
if (result.success) {
statusEl.className = 'status success';
statusEl.textContent = 'New passkey registered successfully!';
registerSection.classList.add('hidden');
successSection.classList.remove('hidden');
} else {
throw new Error(result.error || 'Registration failed');
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to register passkey: ' + e.message;
btn.disabled = false;
btn.textContent = 'Add New Passkey';
}
}
verifyToken();
</script>
</body>
</html>
`);
});
// ============================================================================
// 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: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#129309;</div>
<h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Guardian Invitation</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${name}</strong>,</p>
<p><strong>${username}</strong> has asked you to be a <strong>recovery guardian</strong> for their rStack Identity account.</p>
<p style="color:#94a3b8;font-size:13px;">As a guardian, you can help them recover their account if they lose access. You will never have access to their account or data.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${inviteUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Accept Invitation</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This invitation expires in <strong>7 days</strong>.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${inviteUrl}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} 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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guardian Invitation — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.info-box { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; text-align: left; font-size: 0.85rem; color: #e2e8f0; line-height: 1.6; }
.info-box strong { color: #00d4ff; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-outline { background: transparent; border: 1px solid rgba(255,255,255,0.2); margin-left: 0.5rem; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#129309;</div>
<h1>Guardian Invitation</h1>
<div id="status" class="status loading">Loading invitation...</div>
<div id="info" class="hidden">
<div class="info-box">
<p><strong id="owner-name">Someone</strong> wants you as a recovery guardian.</p>
<p style="margin-top:0.5rem;color:#94a3b8;">If they ever lose access to their devices, you'll be asked to approve their account recovery. You won't have access to their account or data.</p>
</div>
<div id="accept-section">
<p style="font-size:0.85rem;color:#94a3b8;margin-bottom:1rem;">To accept, sign in with your passkey (or create an account).</p>
<button class="btn" id="accept-btn" onclick="acceptInvite()">Accept & Sign In</button>
</div>
</div>
<div id="done" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You're now a guardian. If <strong id="done-owner">they</strong> ever need recovery, you'll receive a notification.</p>
</div>
</div>
<script type="module">
import { authenticatePasskey, registerPasskey } from '/dist/index.js';
const params = new URLSearchParams(window.location.search);
const inviteToken = params.get('token');
const statusEl = document.getElementById('status');
let ownerUsername = '';
async function loadInvite() {
if (!inviteToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No invitation token found.';
return;
}
try {
const res = await fetch('/api/guardian/accept?token=' + encodeURIComponent(inviteToken));
const data = await res.json();
if (data.error) {
statusEl.className = 'status error';
statusEl.textContent = data.error;
return;
}
ownerUsername = data.ownerUsername;
document.getElementById('owner-name').textContent = data.ownerUsername;
statusEl.className = 'status success';
statusEl.textContent = 'Invitation from ' + data.ownerUsername + ' — ' + data.guardianName;
document.getElementById('info').classList.remove('hidden');
} catch {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to load invitation.';
}
}
window.acceptInvite = async () => {
const btn = document.getElementById('accept-btn');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
// Try to authenticate first
let authResult;
try {
authResult = await authenticatePasskey();
} catch {
// If no passkey, prompt to register
const username = prompt('No passkey found. Create an account?\\nChoose a username:');
if (!username) { btn.disabled = false; btn.textContent = 'Accept & Sign In'; return; }
authResult = await registerPasskey(username, username);
// Complete registration
const regRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: authResult.challenge, credential: authResult.credential, userId: authResult.userId, username }),
});
const regData = await regRes.json();
if (!regData.success) throw new Error(regData.error || 'Registration failed');
localStorage.setItem('encryptid_token', regData.token);
// Now accept
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + regData.token },
body: JSON.stringify({ inviteToken }),
});
const acceptData = await acceptRes.json();
if (acceptData.error) throw new Error(acceptData.error);
statusEl.className = 'status success';
statusEl.textContent = 'You are now a guardian!';
document.getElementById('info').classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
document.getElementById('done-owner').textContent = ownerUsername;
return;
}
// Complete authentication
const authRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: authResult.challenge, credential: authResult.credential }),
});
const authData = await authRes.json();
if (!authData.success) throw new Error(authData.error || 'Auth failed');
localStorage.setItem('encryptid_token', authData.token);
// Accept the guardian invite
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authData.token },
body: JSON.stringify({ inviteToken }),
});
const acceptData = await acceptRes.json();
if (acceptData.error) throw new Error(acceptData.error);
statusEl.className = 'status success';
statusEl.textContent = 'You are now a guardian!';
document.getElementById('info').classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
document.getElementById('done-owner').textContent = ownerUsername;
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed: ' + err.message;
btn.disabled = false;
btn.textContent = 'Accept & Sign In';
}
};
loadInvite();
</script>
</body>
</html>
`);
});
/**
* 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);
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);
// 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}`;
// 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: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#128272;</div>
<h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Recovery Request</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${guardian.name}</strong>,</p>
<p><strong>${user.username}</strong> is trying to recover their rStack Identity account and needs your approval.</p>
<p style="color:#94a3b8;font-size:13px;">2 of 3 guardians must approve for recovery to proceed.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${approveUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#22c55e,#16a34a);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Approve Recovery</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This request expires in <strong>7 days</strong>.</p>
<p style="color:#ef4444;margin-top:8px;">If you did not expect this request, do NOT click the button.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${approveUrl}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} 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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Approve Recovery — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128272;</div>
<h1>Recovery Approval</h1>
<div id="status" class="status loading">Verifying approval link...</div>
<button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const approvalToken = params.get('token');
const statusEl = document.getElementById('status');
const btn = document.getElementById('approve-btn');
async function init() {
if (!approvalToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No approval token found in link.';
return;
}
// Show confirmation step
statusEl.className = 'status warning';
statusEl.textContent = 'You are about to approve a recovery request. Only do this if the account owner contacted you directly and asked for help recovering their account.';
btn.classList.remove('hidden');
}
async function doApprove() {
btn.disabled = true;
btn.textContent = 'Approving...';
try {
const res = await fetch('/api/recovery/social/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvalToken }),
});
const data = await res.json();
if (data.error) {
statusEl.className = 'status error';
statusEl.textContent = data.error;
btn.classList.add('hidden');
return;
}
statusEl.className = 'status success';
statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
(data.approvalCount >= data.threshold ? '<br><br>The account owner can now recover their account.' : '<br><br>Waiting for more guardians to approve.');
btn.classList.add('hidden');
} catch {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to process approval.';
btn.disabled = false;
btn.textContent = 'Confirm Approval';
}
}
init();
</script>
</body>
</html>
`);
});
/**
* 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');
}
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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Device — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128241;</div>
<h1>Link This Device</h1>
<div id="status" class="status loading">Loading...</div>
<button id="link-btn" class="btn hidden" onclick="linkDevice()">Create Passkey on This Device</button>
<div id="done" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You can now sign in from this device using your passkey.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const linkToken = params.get('token');
const statusEl = document.getElementById('status');
const btn = document.getElementById('link-btn');
let linkInfo = null;
async function init() {
if (!linkToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No link token found.';
return;
}
try {
const res = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/info');
const data = await res.json();
if (data.error) {
statusEl.className = 'status error';
statusEl.textContent = data.error;
return;
}
linkInfo = data;
statusEl.className = 'status success';
statusEl.textContent = 'Link this device to ' + data.username + '\\'s account';
btn.classList.remove('hidden');
} catch {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to load link info.';
}
}
function b64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
}
async function linkDevice() {
btn.disabled = true;
btn.textContent = 'Creating passkey...';
try {
// Get registration options from the server
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: linkInfo.username }),
});
const { options, userId } = await startRes.json();
// Decode for WebAuthn
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const credential = await navigator.credentials.create({ publicKey: options });
const credentialData = {
credentialId: b64url(credential.rawId),
publicKey: b64url(credential.response.getPublicKey?.() || new ArrayBuffer(0)),
transports: credential.response.getTransports?.() || [],
};
// Complete via device-link endpoint (not register/complete)
const completeRes = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credentialData }),
});
const data = await completeRes.json();
if (data.error) throw new Error(data.error);
statusEl.className = 'status success';
statusEl.textContent = 'Device linked successfully!';
btn.classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed: ' + err.message;
btn.disabled = false;
btn.textContent = 'Create Passkey on This Device';
}
}
init();
</script>
</body>
</html>
`);
});
// ============================================================================
// SPACE MEMBERSHIP ROUTES
// ============================================================================
const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin'];
// Helper: verify JWT and return claims, or null
async function verifyTokenFromRequest(authorization: string | undefined): Promise<{
sub: string; did?: string; username?: string;
} | 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 };
} catch {
return null;
}
}
// 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 });
});
// ============================================================================
// 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 });
});
// ============================================================================
// 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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rStack Identity — One Passkey for the r* Ecosystem</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.page { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem; }
/* Header */
.header { text-align: center; margin-bottom: 2.5rem; }
.header h1 {
font-size: 2.5rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .tagline { font-size: 1.1rem; color: #94a3b8; margin-top: 0.5rem; }
/* Auth card */
.auth-card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2.5rem;
}
.auth-card h2 { font-size: 1.25rem; margin-bottom: 0.25rem; }
.auth-card .sub { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1.5rem; }
/* Tabs */
.tabs { display: flex; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); margin-bottom: 1.5rem; }
.tab { flex: 1; padding: 0.6rem; text-align: center; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: background 0.2s; background: transparent; color: #94a3b8; border: none; }
.tab.active { background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; }
/* Form */
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
.form-group input {
width: 100%; padding: 0.7rem 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #fff; font-size: 0.95rem; outline: none; transition: border 0.2s;
}
.form-group input:focus { border-color: #7c3aed; }
.form-group input::placeholder { color: #475569; }
.btn-primary {
width: 100%; padding: 0.75rem; border-radius: 0.5rem; border: none;
background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.15s, opacity 0.15s;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem; color: #fca5a5; margin-bottom: 1rem; display: none; }
.success { background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3); border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem; color: #86efac; margin-bottom: 1rem; display: none; }
/* Profile (shown when logged in) */
.profile { display: none; }
.profile-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.profile-avatar { width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, #00d4ff, #7c3aed); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; flex-shrink: 0; }
.profile-info h3 { font-size: 1.1rem; }
.profile-info .did { font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; }
.profile-row { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid rgba(255,255,255,0.08); font-size: 0.875rem; }
.profile-row .label { color: #94a3b8; }
.profile-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; flex-wrap: wrap; }
.btn-secondary {
padding: 0.6rem 1.25rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
background: transparent; color: #fff; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: background 0.2s;
}
.btn-secondary:hover { background: rgba(255,255,255,0.08); }
.btn-danger { border-color: rgba(239,68,68,0.4); color: #fca5a5; }
.btn-danger:hover { background: rgba(239,68,68,0.15); }
/* Passkeys list */
.passkeys { margin-top: 1.5rem; }
.passkeys h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.passkey-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
.passkey-id { font-family: monospace; color: #94a3b8; }
.passkey-date { color: #64748b; }
/* Recovery email */
.recovery-section, .guardians-section, .devices-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
.recovery-section h4, .guardians-section h4, .devices-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.recovery-row { display: flex; gap: 0.5rem; }
.recovery-row input { flex: 1; }
.recovery-row button { white-space: nowrap; }
/* Guardian items */
.guardian-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.85rem; }
.guardian-name { font-weight: 500; }
.guardian-status { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 1rem; }
.guardian-status.pending { background: rgba(234,179,8,0.15); color: #eab308; }
.guardian-status.accepted { background: rgba(34,197,94,0.15); color: #22c55e; }
.guardian-add { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.guardian-add input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #fff; font-size: 0.85rem; outline: none; }
.guardian-add input:focus { border-color: #7c3aed; }
/* Setup checklist */
.setup-checklist { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
.setup-checklist h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.check-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; font-size: 0.85rem; }
.check-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; flex-shrink: 0; }
.check-icon.done { background: rgba(34,197,94,0.2); color: #22c55e; }
.check-icon.todo { background: rgba(255,255,255,0.08); color: #64748b; }
/* Invite link display */
.invite-link { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; padding: 0.6rem; font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; margin-top: 0.5rem; cursor: pointer; position: relative; }
.invite-link:hover { border-color: #7c3aed; }
.invite-link .copy-hint { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-family: sans-serif; font-size: 0.7rem; color: #7c3aed; }
/* QR placeholder */
.qr-container { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
.qr-container canvas { border-radius: 8px; background: #fff; padding: 8px; }
.or-divider { color: #64748b; font-size: 0.8rem; margin: 0.5rem 0; }
/* Features */
.features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 2rem; }
.feature { background: rgba(255,255,255,0.04); padding: 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.08); text-align: center; }
.feature-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
.feature-desc { font-size: 0.8rem; color: #94a3b8; }
/* Apps bar */
.apps { text-align: center; padding-top: 2rem; border-top: 1px solid rgba(255,255,255,0.08); }
.apps-title { font-size: 0.8rem; color: #64748b; margin-bottom: 0.75rem; }
.app-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; }
.app-links a { color: #64748b; text-decoration: none; font-size: 0.85rem; transition: color 0.2s; }
.app-links a:hover { color: #00d4ff; }
.link-row { text-align: center; margin-top: 1rem; font-size: 0.8rem; }
.link-row a { color: #7c3aed; text-decoration: none; }
.link-row a:hover { text-decoration: underline; }
@media (max-width: 480px) {
.features { grid-template-columns: 1fr; }
.header h1 { font-size: 2rem; }
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1>rStack Identity</h1>
<p class="tagline">One passkey for the <a href="https://rstack.online" style="color:#00d4ff;text-decoration:none">rStack.online</a> ecosystem</p>
</div>
<!-- Auth card: login/register when signed out, profile when signed in -->
<div class="auth-card">
<!-- Login/Register form -->
<div id="auth-form">
<h2>Get started</h2>
<p class="sub">Sign in with your passkey or create a new account. No passwords, no tracking, no third parties.</p>
<div class="tabs">
<button class="tab active" id="tab-signin" onclick="switchTab('signin')">Sign In</button>
<button class="tab" id="tab-register" onclick="switchTab('register')">Register</button>
</div>
<div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div>
<!-- Register fields (hidden in signin mode) -->
<div id="register-fields" style="display:none">
<div class="form-group">
<label for="username">Username</label>
<input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
</div>
<div class="form-group">
<label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional — for account recovery)</span></label>
<input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
</div>
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
<div class="link-row" id="recovery-link-row">
<a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
</div>
<!-- Recovery form (hidden by default) -->
<div id="recovery-form" style="display:none;margin-top:1rem;">
<div class="form-group">
<label for="recover-email">Email or Username</label>
<input id="recover-email" type="text" placeholder="Enter your email or username" />
</div>
<button class="btn-primary" style="background:linear-gradient(90deg,#22c55e,#16a34a);" onclick="initiateRecovery()">Send Recovery Request</button>
<div id="recover-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
</div>
<!-- Profile (shown when logged in) -->
<div id="profile" class="profile">
<div class="profile-header">
<div class="profile-avatar" id="profile-avatar">?</div>
<div class="profile-info">
<h3 id="profile-username">Loading...</h3>
<div class="did" id="profile-did"></div>
</div>
</div>
<div class="profile-row"><span class="label">Session</span><span id="profile-session">Active</span></div>
<div class="profile-row"><span class="label">Token expires</span><span id="profile-expires">--</span></div>
<div class="passkeys">
<h4>Your Passkeys</h4>
<div id="passkey-list"><div class="passkey-item"><span class="passkey-id">Loading...</span></div></div>
</div>
<div class="setup-checklist" id="setup-checklist">
<h4>Account Security</h4>
<div class="check-item"><div class="check-icon done">&#10003;</div> Passkey created</div>
<div class="check-item"><div class="check-icon" id="check-email">&#9675;</div> <span id="check-email-text">Recovery email</span></div>
<div class="check-item"><div class="check-icon" id="check-device">&#9675;</div> <span id="check-device-text">Second device</span></div>
<div class="check-item"><div class="check-icon" id="check-guardians">&#9675;</div> <span id="check-guardians-text">Guardians (0/3)</span></div>
</div>
<div class="recovery-section">
<h4>Recovery Email</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Recommended for cross-device login and account recovery.</p>
<div class="recovery-row">
<input id="recovery-email" type="email" placeholder="you@example.com" style="padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.05);color:#fff;font-size:0.85rem;outline:none" />
<button class="btn-secondary" onclick="setRecoveryEmail()">Save</button>
</div>
<div id="recovery-msg" style="font-size:0.8rem;margin-top:0.5rem;color:#86efac;display:none"></div>
</div>
<div class="devices-section">
<h4>Linked Devices</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Add a passkey on another device so you can sign in from anywhere.</p>
<div id="device-list"></div>
<button class="btn-secondary" onclick="startDeviceLink()" id="link-device-btn">+ Link Another Device</button>
<div id="device-link-area" class="hidden">
<div class="qr-container">
<canvas id="qr-canvas" width="200" height="200"></canvas>
<p style="font-size:0.8rem;color:#94a3b8;">Scan this QR code on your other device</p>
</div>
<div class="or-divider">— or —</div>
<div class="invite-link" id="device-link-url" onclick="copyDeviceLink()">
<span id="device-link-text"></span>
<span class="copy-hint">click to copy</span>
</div>
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem;">Link expires in 10 minutes</p>
</div>
</div>
<div class="guardians-section">
<h4>Recovery Guardians</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Choose 3 people you trust. If you lose all your devices, any 2 of them can help you recover your account.</p>
<div id="guardian-list"></div>
<div id="guardian-add-form" class="guardian-add">
<input id="guardian-name" type="text" placeholder="Name (e.g. Mom, Alex)" />
<input id="guardian-email" type="email" placeholder="Email (optional)" />
<button class="btn-secondary" onclick="addGuardian()">Add</button>
</div>
<div id="guardian-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
<div id="guardian-invite-area" class="hidden" style="margin-top:0.75rem;">
<p style="font-size:0.8rem;color:#94a3b8;margin-bottom:0.5rem;">Share this invite link with your guardian:</p>
<div class="invite-link" id="guardian-invite-link" onclick="copyGuardianLink()">
<span id="guardian-invite-text"></span>
<span class="copy-hint">click to copy</span>
</div>
</div>
</div>
<div class="profile-actions">
<a href="/demo.html" class="btn-secondary">SDK Demo</a>
<button class="btn-secondary btn-danger" onclick="handleLogout()">Sign Out</button>
</div>
</div>
</div>
<div class="features">
<div class="feature">
<div class="feature-title">Passkey Auth</div>
<div class="feature-desc">Hardware-backed, phishing-resistant login. No passwords ever.</div>
</div>
<div class="feature">
<div class="feature-title">Guardian Recovery</div>
<div class="feature-desc">3 trusted contacts, 2 to recover. No seed phrases, no single point of failure.</div>
</div>
<div class="feature">
<div class="feature-title">E2E Encryption</div>
<div class="feature-desc">Derive keys from your passkey. Keys never leave your device.</div>
</div>
<div class="feature">
<div class="feature-title">Cross-App Identity</div>
<div class="feature-desc">One passkey works across every app in the rStack ecosystem.</div>
</div>
</div>
<div class="apps">
<div class="apps-title">One identity across the <a href="https://rstack.online" style="color:#64748b;text-decoration:none">rStack.online</a> ecosystem</div>
<div class="app-links">
<a href="https://rspace.online">rSpace</a>
<a href="https://rnotes.online">rNotes</a>
<a href="https://rfiles.online">rFiles</a>
<a href="https://rcart.online">rCart</a>
<a href="https://rfunds.online">rFunds</a>
<a href="https://rwallet.online">rWallet</a>
<a href="https://rauctions.online">rAuctions</a>
<a href="https://rpubs.online">rPubs</a>
<a href="https://rvote.online">rVote</a>
<a href="https://rmaps.online">rMaps</a>
<a href="https://rtrips.online">rTrips</a>
<a href="https://rtube.online">rTube</a>
<a href="https://rinbox.online">rInbox</a>
<a href="https://rstack.online">rStack</a>
</div>
</div>
</div>
<script type="module">
import {
registerPasskey,
authenticatePasskey,
getKeyManager,
getSessionManager,
detectCapabilities,
} from '/dist/index.js';
const TOKEN_KEY = 'encryptid_token';
let currentMode = 'signin';
// Expose to inline onclick handlers
window.switchTab = (mode) => {
currentMode = mode;
document.getElementById('tab-signin').classList.toggle('active', mode === 'signin');
document.getElementById('tab-register').classList.toggle('active', mode === 'register');
document.getElementById('register-fields').style.display = mode === 'register' ? 'block' : 'none';
document.getElementById('auth-btn').textContent = mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
hideMessages();
};
function showError(msg) {
const el = document.getElementById('error-msg');
el.textContent = msg; el.style.display = 'block';
document.getElementById('success-msg').style.display = 'none';
}
function showSuccess(msg) {
const el = document.getElementById('success-msg');
el.textContent = msg; el.style.display = 'block';
document.getElementById('error-msg').style.display = 'none';
}
function hideMessages() {
document.getElementById('error-msg').style.display = 'none';
document.getElementById('success-msg').style.display = 'none';
}
window.showRecoveryForm = () => {
document.getElementById('recovery-form').style.display = 'block';
document.getElementById('recovery-link-row').style.display = 'none';
};
window.initiateRecovery = async () => {
const input = document.getElementById('recover-email').value.trim();
const msgEl = document.getElementById('recover-msg');
if (!input) { msgEl.textContent = 'Enter your email or username'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; }
try {
const body = input.includes('@') ? { email: input } : { username: input };
const res = await fetch('/api/recovery/social/initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.';
msgEl.style.color = '#86efac';
msgEl.style.display = 'block';
// Also try email recovery
if (input.includes('@')) {
fetch('/api/recovery/email/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: input }),
}).catch(() => {});
}
} catch (err) {
msgEl.textContent = 'Failed: ' + err.message;
msgEl.style.color = '#fca5a5';
msgEl.style.display = 'block';
}
};
window.handleAuth = async () => {
const btn = document.getElementById('auth-btn');
btn.disabled = true;
hideMessages();
try {
if (currentMode === 'register') {
const username = document.getElementById('username').value.trim();
if (!username) { showError('Username is required'); btn.disabled = false; return; }
const email = document.getElementById('reg-email').value.trim();
btn.textContent = 'Creating passkey...';
const credential = await registerPasskey(username, username);
// Complete registration with server
const regBody = {
challenge: credential.challenge,
credential: credential.credential,
userId: credential.userId,
username,
};
if (email) regBody.email = email;
const res = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regBody),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
localStorage.setItem(TOKEN_KEY, data.token);
showProfile(data.token, username, data.did);
} else {
btn.textContent = 'Waiting for passkey...';
const result = await authenticatePasskey();
// Complete auth with server
const res = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: result.challenge,
credential: result.credential,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Authentication failed');
localStorage.setItem(TOKEN_KEY, data.token);
showProfile(data.token, data.username, data.did);
}
} catch (err) {
showError(err.message || 'Authentication failed');
btn.textContent = currentMode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
btn.disabled = false;
}
};
async function showProfile(token, username, did) {
document.getElementById('auth-form').style.display = 'none';
document.getElementById('profile').style.display = 'block';
document.getElementById('profile-username').textContent = username || 'Anonymous';
document.getElementById('profile-avatar').textContent = (username || '?')[0].toUpperCase();
document.getElementById('profile-did').textContent = did || '';
// Parse token expiry
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = new Date(payload.exp * 1000);
document.getElementById('profile-expires').textContent = exp.toLocaleString();
} catch { document.getElementById('profile-expires').textContent = '--'; }
// Fetch passkeys
try {
const res = await fetch('/api/user/credentials', {
headers: { 'Authorization': 'Bearer ' + token },
});
const data = await res.json();
const list = document.getElementById('passkey-list');
if (data.credentials && data.credentials.length > 0) {
list.innerHTML = data.credentials.map(c => {
const created = c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '';
return '<div class="passkey-item"><span class="passkey-id">' +
c.credentialId.slice(0, 24) + '...</span><span class="passkey-date">' + created + '</span></div>';
}).join('');
} else {
list.innerHTML = '<div class="passkey-item"><span class="passkey-id">No passkeys found</span></div>';
}
} catch { /* ignore */ }
// Load guardians
loadGuardians();
}
window.handleLogout = () => {
localStorage.removeItem(TOKEN_KEY);
document.getElementById('auth-form').style.display = 'block';
document.getElementById('profile').style.display = 'none';
const btn = document.getElementById('auth-btn');
btn.disabled = false;
btn.textContent = 'Sign In with Passkey';
currentMode = 'signin';
switchTab('signin');
};
window.setRecoveryEmail = async () => {
const email = document.getElementById('recovery-email').value.trim();
if (!email) return;
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
const msgEl = document.getElementById('recovery-msg');
try {
const res = await fetch('/api/recovery/email/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed');
msgEl.textContent = 'Recovery email saved.';
msgEl.style.color = '#86efac';
msgEl.style.display = 'block';
updateChecklist();
} catch (err) {
msgEl.textContent = err.message;
msgEl.style.color = '#fca5a5';
msgEl.style.display = 'block';
}
};
// ── Guardian management ──
let lastGuardianInviteUrl = '';
window.addGuardian = async () => {
const name = document.getElementById('guardian-name').value.trim();
const email = document.getElementById('guardian-email').value.trim();
const token = localStorage.getItem(TOKEN_KEY);
const msgEl = document.getElementById('guardian-msg');
if (!name) { msgEl.textContent = 'Name is required'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; }
if (!token) return;
try {
const res = await fetch('/api/guardians', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name, email: email || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed');
// Clear inputs
document.getElementById('guardian-name').value = '';
document.getElementById('guardian-email').value = '';
// Show invite link
lastGuardianInviteUrl = data.inviteUrl;
document.getElementById('guardian-invite-text').textContent = data.inviteUrl;
document.getElementById('guardian-invite-area').classList.remove('hidden');
if (email) {
msgEl.textContent = 'Guardian added! Invite email sent to ' + email;
} else {
msgEl.textContent = 'Guardian added! Share the invite link below.';
}
msgEl.style.color = '#86efac';
msgEl.style.display = 'block';
loadGuardians();
} catch (err) {
msgEl.textContent = err.message;
msgEl.style.color = '#fca5a5';
msgEl.style.display = 'block';
}
};
window.copyGuardianLink = () => {
navigator.clipboard.writeText(lastGuardianInviteUrl).then(() => {
const el = document.getElementById('guardian-msg');
el.textContent = 'Link copied!';
el.style.color = '#86efac';
el.style.display = 'block';
});
};
window.removeGuardian = async (id) => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
if (!confirm('Remove this guardian?')) return;
await fetch('/api/guardians/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
loadGuardians();
};
async function loadGuardians() {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
try {
const res = await fetch('/api/guardians', { headers: { 'Authorization': 'Bearer ' + token } });
const data = await res.json();
const list = document.getElementById('guardian-list');
if (data.guardians && data.guardians.length > 0) {
list.innerHTML = data.guardians.map(g =>
'<div class="guardian-item">' +
'<span><span class="guardian-name">' + g.name + '</span>' +
(g.email ? ' <span style="color:#64748b;font-size:0.75rem">(' + g.email + ')</span>' : '') +
'</span>' +
'<span style="display:flex;align-items:center;gap:0.5rem;">' +
'<span class="guardian-status ' + g.status + '">' + g.status + '</span>' +
'<button onclick="removeGuardian(\'' + g.id + '\')" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:0.8rem;" title="Remove">&times;</button>' +
'</span></div>'
).join('');
// Hide add form if 3 guardians
document.getElementById('guardian-add-form').style.display = data.guardians.length >= 3 ? 'none' : 'flex';
} else {
list.innerHTML = '';
}
updateChecklist(data.guardians);
} catch { /* ignore */ }
}
// ── Device linking ──
let deviceLinkUrl = '';
window.startDeviceLink = async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
const btn = document.getElementById('link-device-btn');
btn.disabled = true;
try {
const res = await fetch('/api/device-link/start', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
});
const data = await res.json();
if (data.error) throw new Error(data.error);
deviceLinkUrl = data.linkUrl;
document.getElementById('device-link-text').textContent = data.linkUrl;
document.getElementById('device-link-area').classList.remove('hidden');
btn.style.display = 'none';
// Generate QR code
generateQR(data.linkUrl, document.getElementById('qr-canvas'));
} catch (err) {
alert('Failed: ' + err.message);
btn.disabled = false;
}
};
window.copyDeviceLink = () => {
navigator.clipboard.writeText(deviceLinkUrl);
};
// Simple QR code generator (alphanumeric mode, version auto)
function generateQR(text, canvas) {
// Use a minimal inline QR approach via canvas
// For simplicity, draw a placeholder with the URL
const ctx = canvas.getContext('2d');
const size = canvas.width;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, size, size);
// We'll use a simple approach: encode as a data URL and render
// For production, we'd use a proper QR library, but for now
// let's create a visual pattern from the URL hash
const hash = Array.from(text).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0);
const cellSize = 4;
const modules = Math.floor(size / cellSize);
ctx.fillStyle = '#000';
// Generate a deterministic pattern (not a real QR code)
// We'll load a proper QR via dynamic import if available
try {
// Try to use the QR code API endpoint
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { ctx.drawImage(img, 0, 0, size, size); };
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(text) + '&format=png&margin=8';
} catch {
// Fallback: just show text
ctx.fillStyle = '#000';
ctx.font = '10px monospace';
ctx.fillText('Scan QR on phone', 20, size/2 - 10);
ctx.fillText('or copy link below', 20, size/2 + 10);
}
}
// ── Checklist update ──
function updateChecklist(guardians) {
const token = localStorage.getItem(TOKEN_KEY);
// Email check
const emailInput = document.getElementById('recovery-email');
const hasEmail = emailInput && emailInput.value.trim();
const emailIcon = document.getElementById('check-email');
if (hasEmail) {
emailIcon.className = 'check-icon done';
emailIcon.innerHTML = '&#10003;';
}
// Device check (from passkey count)
const passkeyItems = document.querySelectorAll('.passkey-item');
const deviceIcon = document.getElementById('check-device');
const deviceText = document.getElementById('check-device-text');
if (passkeyItems.length > 1) {
deviceIcon.className = 'check-icon done';
deviceIcon.innerHTML = '&#10003;';
deviceText.textContent = passkeyItems.length + ' devices linked';
}
// Guardian check
const guardianIcon = document.getElementById('check-guardians');
const guardianText = document.getElementById('check-guardians-text');
const gList = guardians || [];
const accepted = gList.filter(g => g.status === 'accepted').length;
const total = gList.length;
guardianText.textContent = 'Guardians (' + total + '/3' + (accepted < total ? ', ' + accepted + ' accepted' : '') + ')';
if (total >= 3 && accepted >= 2) {
guardianIcon.className = 'check-icon done';
guardianIcon.innerHTML = '&#10003;';
} else if (total > 0) {
guardianIcon.className = 'check-icon todo';
guardianIcon.innerHTML = total.toString();
}
}
// On page load: check for existing token
(async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
try {
const res = await fetch('/api/session/verify', {
headers: { 'Authorization': 'Bearer ' + token },
});
const data = await res.json();
if (data.valid) {
showProfile(token, data.username, data.did);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch {
localStorage.removeItem(TOKEN_KEY);
}
})();
</script>
</body>
</html>
`);
});
// ============================================================================
// DATABASE INITIALIZATION & SERVER START
// ============================================================================
// Initialize database on startup
initDatabase().catch(err => {
console.error('EncryptID: Failed to initialize database', err);
process.exit(1);
});
// Clean expired challenges and recovery tokens every 10 minutes
setInterval(() => {
cleanExpiredChallenges().catch(() => {});
cleanExpiredRecoveryTokens().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,
};