3078 lines
119 KiB
TypeScript
3078 lines
119 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,
|
|
} from './db.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',
|
|
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;">🔒</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);
|
|
|
|
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;">✉</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 });
|
|
});
|
|
|
|
/**
|
|
* 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">🔒</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;">🤝</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">🤝</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;">🔐</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">🔐</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">📱</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 });
|
|
});
|
|
|
|
// ============================================================================
|
|
// 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">✓</div> Passkey created</div>
|
|
<div class="check-item"><div class="check-icon" id="check-email">○</div> <span id="check-email-text">Recovery email</span></div>
|
|
<div class="check-item"><div class="check-icon" id="check-device">○</div> <span id="check-device-text">Second device</span></div>
|
|
<div class="check-item"><div class="check-icon" id="check-guardians">○</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">×</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 = '✓';
|
|
}
|
|
// 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 = '✓';
|
|
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 = '✓';
|
|
} 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,
|
|
};
|