rspace-online/src/encryptid/server.ts

5344 lines
205 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,
getUserByUsername,
storeRecoveryToken,
getRecoveryToken,
markRecoveryTokenUsed,
type StoredCredential,
type StoredChallenge,
type StoredRecoveryToken,
addGuardian,
getGuardians,
getGuardianByInviteToken,
acceptGuardianInvite,
removeGuardian,
getGuardianById,
getGuardianships,
createRecoveryRequest,
getRecoveryRequest,
getActiveRecoveryRequest,
createRecoveryApproval,
approveRecoveryByToken,
updateRecoveryRequestStatus,
getRecoveryApprovals,
createDeviceLink,
getDeviceLink,
markDeviceLinkUsed,
getSpaceMember,
listSpaceMembers,
listSpacesForUser,
upsertSpaceMember,
removeSpaceMember,
getUserProfile,
updateUserProfile,
getUserAddresses,
getAddressById,
saveUserAddress,
deleteUserAddress,
getEmailForwardStatus,
setEmailForward,
listAllUsers,
deleteUser,
deleteSpaceMembers,
createSpaceInvite,
getSpaceInviteByToken,
listSpaceInvites,
acceptSpaceInvite,
revokeSpaceInvite,
createFundClaim,
getFundClaimByToken,
getFundClaimsByEmailHash,
acceptFundClaim,
accumulateFundClaim,
cleanExpiredFundClaims,
getOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
cleanExpiredOidcCodes,
seedOidcClients,
createIdentityInvite,
getIdentityInviteByToken,
getIdentityInvitesByInviter,
claimIdentityInvite,
revokeIdentityInvite,
cleanExpiredIdentityInvites,
sql,
} from './db.js';
import {
isMailcowConfigured,
createAlias,
deleteAlias,
updateAlias,
aliasExists,
} from './mailcow.js';
import { notify } from '../../server/notification-service';
// ============================================================================
// CONFIGURATION
// ============================================================================
const CONFIG = {
port: process.env.PORT || 3000,
rpId: 'rspace.online',
rpName: 'EncryptID',
jwtSecret: (() => {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET environment variable is required');
return secret;
})(),
sessionDuration: 30 * 24 * 60 * 60, // 30 days
refreshDuration: 7 * 24 * 60 * 60, // 7 days
smtp: {
host: process.env.SMTP_HOST || 'mail.rmail.online',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false, // STARTTLS on 587
user: process.env.SMTP_USER || 'noreply@rspace.online',
pass: process.env.SMTP_PASS || '',
from: process.env.SMTP_FROM || 'EncryptID <noreply@rspace.online>',
},
recoveryUrl: process.env.RECOVERY_URL || 'https://auth.rspace.online/recover',
adminDIDs: (process.env.ADMIN_DIDS || '').split(',').filter(Boolean),
allowedOrigins: [
// rspace.online — RP ID domain and all subdomains
'https://rspace.online',
'https://auth.rspace.online',
'https://cca.rspace.online',
'https://demo.rspace.online',
'https://app.rspace.online',
'https://dev.rspace.online',
// r* ecosystem apps (each *.online is an eTLD+1 — Related Origins limit is 5)
'https://rwallet.online',
'https://rvote.online',
'https://rmaps.online',
'https://rfiles.online',
'https://rnotes.online',
'https://rflows.online',
'https://rtrips.online',
'https://rnetwork.online',
'https://rcart.online',
'https://rtube.online',
'https://rchats.online',
'https://rstack.online',
'https://rpubs.online',
'https://rauctions.online',
'https://ridentity.online',
'https://auth.ridentity.online',
'https://rphotos.online',
'https://rcal.online',
'https://rinbox.online',
'https://rmail.online',
'https://rsocials.online',
'https://demo.rsocials.online',
'https://socials.crypto-commons.org',
'https://socials.p2pfoundation.net',
'https://rwork.online',
'https://rforum.online',
'https://rchoices.online',
'https://rswag.online',
'https://rdata.online',
// Development
'http://localhost:3000',
'http://localhost:5173',
],
};
// ============================================================================
// SMTP TRANSPORT
// ============================================================================
let smtpTransport: Transporter | null = null;
if (CONFIG.smtp.pass) {
smtpTransport = createTransport({
host: CONFIG.smtp.host,
port: CONFIG.smtp.port,
secure: CONFIG.smtp.port === 465,
auth: {
user: CONFIG.smtp.user,
pass: CONFIG.smtp.pass,
},
tls: {
rejectUnauthorized: false, // Internal Mailcow uses self-signed cert
},
});
// Verify connection on startup
smtpTransport.verify().then(() => {
console.log('EncryptID: SMTP connected to', CONFIG.smtp.host);
}).catch((err) => {
console.error('EncryptID: SMTP connection failed —', err.message);
console.error('EncryptID: Email recovery will not work until SMTP is configured');
smtpTransport = null;
});
} else {
console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)');
}
async function sendRecoveryEmail(to: string, token: string, username: string): Promise<boolean> {
const recoveryLink = `${CONFIG.recoveryUrl}?token=${encodeURIComponent(token)}`;
if (!smtpTransport) {
console.log(`EncryptID: [NO SMTP] Recovery link for ${to}: ${recoveryLink}`);
return false;
}
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to,
subject: 'rStack — Account Recovery',
text: [
`Hi ${username},`,
'',
'A recovery request was made for your rStack account.',
'Use the link below to add a new passkey:',
'',
recoveryLink,
'',
'This link expires in 30 minutes.',
'If you did not request this, you can safely ignore this email.',
'',
'— rStack Identity',
].join('\n'),
html: `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#128274;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Identity</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${username}</strong>,</p>
<p>A recovery request was made for your rStack account. Click below to add a new passkey:</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${recoveryLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Recover Account</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This link expires in <strong>30 minutes</strong>.</p>
<p>If you didn't request this, you can safely ignore this email — your account is secure.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${recoveryLink}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`,
});
return true;
}
async function sendClaimEmail(to: string, token: string, amount?: string, currency?: string): Promise<boolean> {
const claimLink = `https://auth.rspace.online/claim?token=${encodeURIComponent(token)}`;
const amountStr = amount && currency ? `$${amount} ${currency}` : 'Your funds';
if (!smtpTransport) {
console.log(`EncryptID: [NO SMTP] Claim link for ${to}: ${claimLink}`);
return false;
}
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to,
subject: 'rStack — Funds ready to claim',
text: [
`Hi,`,
'',
`${amountStr} are ready to claim via EncryptID.`,
'Click the link below to sign in and link your funded wallet to your account:',
'',
claimLink,
'',
'This link expires in 7 days.',
'',
'— rStack Identity',
].join('\n'),
html: `
<!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;">&#128176;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Funds Ready to Claim</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p><strong>${amountStr}</strong> have been deposited into a wallet for you.</p>
<p>Sign in with EncryptID to link this wallet to your account — no keys or seed phrases needed.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${claimLink}" 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;">Claim Your Funds</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>7 days</strong>.</p>
<p>If you didn't expect this, you can safely ignore this email.</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;">${claimLink}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`,
});
return true;
}
async function hashEmail(email: string): Promise<string> {
const data = new TextEncoder().encode(email.toLowerCase().trim());
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ============================================================================
// HONO APP
// ============================================================================
const app = new Hono();
// Middleware
app.use('*', logger());
app.use('*', cors({
origin: (origin) => {
// Allow all *.rspace.online subdomains dynamically (any canvas slug)
if (origin === 'https://rspace.online' || origin?.endsWith('.rspace.online')) {
return origin;
}
// Allow explicit r* ecosystem origins
if (CONFIG.allowedOrigins.includes(origin)) {
return origin;
}
return undefined;
},
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}));
// ============================================================================
// STATIC FILES & WELL-KNOWN
// ============================================================================
// Serve .well-known/webauthn for Related Origins
// Only list non-rspace.online origins here — *.rspace.online subdomains are
// automatically valid because rspace.online is the RP ID.
// Keep to max 5 eTLD+1 labels to stay within browser limits.
app.get('/.well-known/webauthn', (c) => {
const nonRspaceOrigins = CONFIG.allowedOrigins.filter(
o => o.startsWith('https://') && !o.endsWith('.rspace.online') && o !== 'https://rspace.online'
);
return c.json({ origins: nonRspaceOrigins });
});
// Health check — includes database connectivity
app.get('/health', async (c) => {
const dbHealthy = await checkDatabaseHealth();
const status = dbHealthy ? 'ok' : 'degraded';
return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503);
});
// ============================================================================
// RP ID RESOLUTION
// ============================================================================
/**
* Resolve RP ID for WebAuthn ceremonies.
*
* Always returns 'rspace.online' so that all passkeys are registered with
* the same RP ID. The .well-known/webauthn endpoint lists Related Origins,
* allowing browsers on other r*.online domains to use these passkeys.
*/
function resolveRpId(_c: any): string {
return CONFIG.rpId; // Always 'rspace.online'
}
// ============================================================================
// REGISTRATION ENDPOINTS
// ============================================================================
/**
* Start registration - returns challenge and options
*/
app.post('/api/register/start', async (c) => {
const { username, displayName } = await c.req.json();
if (!username) {
return c.json({ error: 'Username required' }, 400);
}
// Generate challenge
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Generate user ID
const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge in database
const challengeRecord: StoredChallenge = {
challenge,
userId,
type: 'registration',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
};
await storeChallenge(challengeRecord);
// Build registration options — use the caller's domain as RP ID
const rpId = resolveRpId(c);
const options = {
challenge,
rp: {
id: rpId,
name: CONFIG.rpName,
},
user: {
id: userId,
name: username,
displayName: displayName || username,
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' }, // RS256
],
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
timeout: 60000,
attestation: 'none',
extensions: {
credProps: true,
},
};
return c.json({ options, userId });
});
/**
* Complete registration - verify and store credential
*/
app.post('/api/register/complete', async (c) => {
const { challenge, credential, userId, username, email } = await c.req.json();
// Verify challenge
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'registration') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
await deleteChallenge(challenge);
// In production, verify the attestation properly
// For now, we trust the client-side verification
// Create user and store credential in database
const did = `did:key:${userId.slice(0, 32)}`;
await createUser(userId, username, username, did);
// Set recovery email if provided during registration
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
await setUserEmail(userId, email);
}
// Resolve the RP ID from the caller's origin
const rpId = resolveRpId(c);
const storedCredential: StoredCredential = {
credentialId: credential.credentialId,
publicKey: credential.publicKey,
userId,
username,
counter: 0,
createdAt: Date.now(),
transports: credential.transports,
rpId,
};
await storeCredential(storedCredential);
console.log('EncryptID: Credential registered', {
credentialId: credential.credentialId.slice(0, 20) + '...',
userId: userId.slice(0, 20) + '...',
});
// Auto-provision user space at <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, 'private', {
enabledModules: DEFAULT_USER_MODULES,
nestPolicy: DEFAULT_USER_NEST_POLICY,
});
// Fire module hooks
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(spaceSlug); } catch (e) {
console.error(`[EncryptID] Module ${mod.id} onSpaceCreate for user space:`, e);
}
}
}
console.log(`EncryptID: Auto-provisioned space ${spaceSlug}.rspace.online for ${username}`);
}
} catch (e) {
// Non-fatal: user is created even if space provisioning fails
console.error('EncryptID: Failed to auto-provision user space:', e);
}
// Generate initial session token
const token = await generateSessionToken(userId, username);
return c.json({
success: true,
userId,
token,
did,
spaceSlug: username.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
});
});
// ============================================================================
// AUTHENTICATION ENDPOINTS
// ============================================================================
/**
* Start authentication - returns challenge
*/
app.post('/api/auth/start', async (c) => {
const body = await c.req.json().catch(() => ({}));
const { credentialId } = body;
// Generate challenge
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge in database
const challengeRecord: StoredChallenge = {
challenge,
type: 'authentication',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000,
};
await storeChallenge(challengeRecord);
// Build allowed credentials if specified
let allowCredentials;
if (credentialId) {
const cred = await getCredential(credentialId);
if (cred) {
allowCredentials = [{
type: 'public-key',
id: credentialId,
transports: cred.transports,
}];
}
}
// Use the caller's domain as RP ID
const rpId = resolveRpId(c);
const options = {
challenge,
rpId,
userVerification: 'required',
timeout: 60000,
allowCredentials,
};
return c.json({ options });
});
/**
* Complete authentication - verify and issue token
*/
app.post('/api/auth/complete', async (c) => {
const { challenge, credential } = await c.req.json();
// Verify challenge from database
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'authentication') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
await deleteChallenge(challenge);
// Look up credential from database
const storedCredential = await getCredential(credential.credentialId);
if (!storedCredential) {
return c.json({ error: 'Unknown credential' }, 400);
}
// In production, verify signature against stored public key
// For now, we trust the client-side verification
// Update counter and last used in database
await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1);
console.log('EncryptID: Authentication successful', {
credentialId: credential.credentialId.slice(0, 20) + '...',
userId: storedCredential.userId.slice(0, 20) + '...',
});
// Generate session token
const token = await generateSessionToken(
storedCredential.userId,
storedCredential.username
);
return c.json({
success: true,
userId: storedCredential.userId,
username: storedCredential.username,
token,
did: `did:key:${storedCredential.userId.slice(0, 32)}`,
});
});
// ============================================================================
// SESSION ENDPOINTS
// ============================================================================
/**
* Verify session token (supports both GET with Authorization header and POST with body)
*/
app.get('/api/session/verify', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ valid: false, error: 'No token' }, 401);
}
const token = authHeader.slice(7);
try {
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
return c.json({
valid: true,
userId: payload.sub,
username: payload.username,
did: payload.did,
exp: payload.exp,
});
} catch {
return c.json({ valid: false, error: 'Invalid token' }, 401);
}
});
app.post('/api/session/verify', async (c) => {
const { token } = await c.req.json();
if (!token) {
return c.json({ valid: false, error: 'No token' }, 400);
}
try {
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
return c.json({
valid: true,
claims: payload,
userId: payload.sub,
username: payload.username,
did: payload.did,
exp: payload.exp,
});
} catch {
return c.json({ valid: false, error: 'Invalid token' });
}
});
/**
* Refresh session token
*/
app.post('/api/session/refresh', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'No token' }, 401);
}
const token = authHeader.slice(7);
try {
const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh
// Issue new token
const newToken = await generateSessionToken(
payload.sub as string,
payload.username as string
);
return c.json({ token: newToken });
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
});
// ============================================================================
// USER INFO ENDPOINTS
// ============================================================================
/**
* Get user credentials (for listing passkeys)
*/
app.get('/api/user/credentials', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
const token = authHeader.slice(7);
try {
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
const userId = payload.sub as string;
const creds = await getUserCredentials(userId);
const credentialList = creds.map(cred => ({
credentialId: cred.credentialId,
createdAt: cred.createdAt,
lastUsed: cred.lastUsed,
transports: cred.transports,
}));
return c.json({ credentials: credentialList });
} catch {
return c.json({ error: 'Unauthorized' }, 401);
}
});
// ============================================================================
// USER PROFILE ENDPOINTS
// ============================================================================
// GET /api/user/profile — get the authenticated user's profile
app.get('/api/user/profile', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const profile = await getUserProfile(claims.sub as string);
if (!profile) return c.json({ error: 'User not found' }, 404);
return c.json({ success: true, profile });
});
// GET /api/user/spaces — list spaces the user is a member of
app.get('/api/user/spaces', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const spaces = await listSpacesForUser(claims.sub);
return c.json({ spaces });
});
// GET /api/user/claims — list pending fund claims for the authenticated user
app.get('/api/user/claims', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const profile = await getUserProfile(claims.sub);
if (!profile?.profileEmail) return c.json({ claims: [] });
const emailHashed = await hashEmail(profile.profileEmail);
const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
return c.json({
claims: pendingClaims.map(cl => ({
id: cl.id,
token: cl.token,
fiatAmount: cl.fiatAmount,
fiatCurrency: cl.fiatCurrency,
walletAddress: cl.walletAddress,
expiresAt: cl.expiresAt,
status: cl.status,
})),
});
});
// PUT /api/user/profile — update the authenticated user's profile
app.put('/api/user/profile', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const body = await c.req.json();
const updates: Record<string, any> = {};
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.bio !== undefined) {
if (typeof body.bio === 'string' && body.bio.length > 500) {
return c.json({ error: 'Bio must be 500 characters or fewer' }, 400);
}
updates.bio = body.bio;
}
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
if (body.profileEmail !== undefined) {
if (body.profileEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.profileEmail)) {
return c.json({ error: 'Invalid email format' }, 400);
}
updates.profileEmail = body.profileEmail;
}
if (body.profileEmailIsRecovery !== undefined) updates.profileEmailIsRecovery = body.profileEmailIsRecovery;
if (body.walletAddress !== undefined) updates.walletAddress = body.walletAddress;
const profile = await updateUserProfile(claims.sub as string, updates);
if (!profile) return c.json({ error: 'User not found' }, 404);
// If profile email changed and forwarding is active, update/disable the alias
if (updates.profileEmail !== undefined && isMailcowConfigured()) {
try {
const fwdStatus = await getEmailForwardStatus(claims.sub as string);
if (fwdStatus?.enabled && fwdStatus.mailcowId) {
if (updates.profileEmail) {
// Email changed — update alias destination
await updateAlias(fwdStatus.mailcowId, updates.profileEmail);
} else {
// Email cleared — disable forwarding
await deleteAlias(fwdStatus.mailcowId);
await setEmailForward(claims.sub as string, false, null);
}
}
} catch (err) {
console.error('EncryptID: Failed to update Mailcow alias after profile email change:', err);
}
}
return c.json({ success: true, profile });
});
// ============================================================================
// ENCRYPTED ADDRESS ENDPOINTS
// ============================================================================
const MAX_ADDRESSES = 10;
const VALID_LABELS = ['home', 'work', 'shipping', 'billing', 'other'];
// GET /api/user/addresses — get all encrypted addresses for the user
app.get('/api/user/addresses', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const addresses = await getUserAddresses(claims.sub as string);
return c.json({
success: true,
addresses: addresses.map(a => ({
id: a.id,
ciphertext: a.ciphertext,
iv: a.iv,
label: a.label,
labelCustom: a.labelCustom,
isDefault: a.isDefault,
})),
});
});
// POST /api/user/addresses — save a new encrypted address
app.post('/api/user/addresses', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const body = await c.req.json();
if (!body.id || !body.ciphertext || !body.iv || !body.label) {
return c.json({ error: 'Missing required fields: id, ciphertext, iv, label' }, 400);
}
if (!VALID_LABELS.includes(body.label)) {
return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400);
}
// Check address limit
const existing = await getUserAddresses(userId);
if (existing.length >= MAX_ADDRESSES) {
return c.json({ error: `Maximum ${MAX_ADDRESSES} addresses allowed` }, 400);
}
const address = await saveUserAddress(userId, {
id: body.id,
ciphertext: body.ciphertext,
iv: body.iv,
label: body.label,
labelCustom: body.labelCustom,
isDefault: body.isDefault || false,
});
return c.json({
success: true,
address: {
id: address.id,
ciphertext: address.ciphertext,
iv: address.iv,
label: address.label,
labelCustom: address.labelCustom,
isDefault: address.isDefault,
},
}, 201);
});
// PUT /api/user/addresses/:id — update an existing encrypted address
app.put('/api/user/addresses/:id', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const id = c.req.param('id');
const body = await c.req.json();
const existing = await getAddressById(id, userId);
if (!existing) return c.json({ error: 'Address not found' }, 404);
if (body.label && !VALID_LABELS.includes(body.label)) {
return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400);
}
const address = await saveUserAddress(userId, {
id,
ciphertext: body.ciphertext || existing.ciphertext,
iv: body.iv || existing.iv,
label: body.label || existing.label,
labelCustom: body.labelCustom !== undefined ? body.labelCustom : existing.labelCustom,
isDefault: body.isDefault !== undefined ? body.isDefault : existing.isDefault,
});
return c.json({
success: true,
address: {
id: address.id,
ciphertext: address.ciphertext,
iv: address.iv,
label: address.label,
labelCustom: address.labelCustom,
isDefault: address.isDefault,
},
});
});
// DELETE /api/user/addresses/:id — delete an encrypted address
app.delete('/api/user/addresses/:id', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const id = c.req.param('id');
const deleted = await deleteUserAddress(id, userId);
if (!deleted) return c.json({ error: 'Address not found' }, 404);
return c.json({ success: true });
});
// ============================================================================
// ACCOUNT SETTINGS ENDPOINTS
// ============================================================================
/**
* GET /api/account/status — account setup completion status
* Returns which security/setup steps are done.
*/
app.get('/api/account/status', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
// Check email
const profile = await getUserProfile(userId);
const hasEmail = !!(profile?.profileEmail);
// Check credentials count (>1 means multi-device)
const creds = await getUserCredentials(userId);
const hasMultiDevice = creds.length > 1;
// Check guardians
let guardianCount = 0;
try {
const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`;
guardianCount = parseInt(rows[0]?.count || '0');
} catch { /* ignore */ }
const hasRecovery = guardianCount >= 2;
// Check encrypted backup
// (This is client-side localStorage, but we can infer from whether they have any synced docs)
// For now, we just return the server-side info and let the client check localStorage
return c.json({
email: hasEmail,
multiDevice: hasMultiDevice,
socialRecovery: hasRecovery,
credentialCount: creds.length,
guardianCount,
});
});
/**
* POST /api/account/email/start — send verification code to email
* Body: { email }
* Auth required
*/
app.post('/api/account/email/start', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { email } = await c.req.json();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const tokenKey = `emailverify_${claims.sub}_${code}`;
await storeRecoveryToken({
token: tokenKey,
userId: claims.sub as string,
type: 'email_verify',
createdAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
used: false,
});
if (smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: email,
subject: `${code} — rStack Email Verification`,
text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`,
html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#9993;</div>
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Email Verification</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;text-align:center;">
<p>Your verification code is:</p>
<div style="font-size:36px;font-weight:800;letter-spacing:8px;color:#06b6d4;padding:16px 0;">${code}</div>
<p style="color:#94a3b8;font-size:13px;">This code expires in 10 minutes.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} catch (err) {
console.error('EncryptID: Failed to send verification email:', err);
return c.json({ error: 'Failed to send verification email' }, 500);
}
} else {
console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`);
}
return c.json({ success: true });
});
/**
* POST /api/account/email/verify — verify code and set email
* Body: { email, code }
* Auth required
*/
app.post('/api/account/email/verify', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { email, code } = await c.req.json();
if (!email || !code) return c.json({ error: 'Email and code required' }, 400);
const tokenKey = `emailverify_${claims.sub}_${code}`;
const rt = await getRecoveryToken(tokenKey);
if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) {
return c.json({ error: 'Invalid or expired verification code' }, 400);
}
await markRecoveryTokenUsed(tokenKey);
await setUserEmail(claims.sub as string, email);
return c.json({ success: true, email });
});
// ============================================================================
// EMAIL FORWARDING (Mailcow alias management)
// ============================================================================
/**
* GET /api/account/email-forward — check forwarding status
* Auth required
*/
app.get('/api/account/email-forward', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const status = await getEmailForwardStatus(claims.sub as string);
if (!status) return c.json({ error: 'User not found' }, 404);
const available = isMailcowConfigured();
const address = `${status.username}@rspace.online`;
return c.json({
enabled: status.enabled,
address: status.enabled ? address : null,
forwardsTo: status.enabled ? status.profileEmail : null,
available,
});
});
/**
* POST /api/account/email-forward/enable — create forwarding alias
* Auth required. Requires a verified profile_email.
*/
app.post('/api/account/email-forward/enable', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
if (!isMailcowConfigured()) {
return c.json({ error: 'Email forwarding is not configured' }, 503);
}
const userId = claims.sub as string;
const status = await getEmailForwardStatus(userId);
if (!status) return c.json({ error: 'User not found' }, 404);
if (status.enabled) {
return c.json({ error: 'Email forwarding is already enabled' }, 409);
}
if (!status.profileEmail) {
return c.json({ error: 'A verified profile email is required to enable forwarding' }, 400);
}
const address = `${status.username}@rspace.online`;
// Check for existing alias conflict
try {
if (await aliasExists(address)) {
return c.json({ error: 'An alias already exists for this address' }, 409);
}
} catch (err) {
console.error('EncryptID: Mailcow aliasExists check failed:', err);
return c.json({ error: 'Email forwarding service unavailable' }, 503);
}
try {
const mailcowId = await createAlias(status.username, status.profileEmail);
await setEmailForward(userId, true, mailcowId);
return c.json({
success: true,
enabled: true,
address,
forwardsTo: status.profileEmail,
});
} catch (err) {
console.error('EncryptID: Failed to create Mailcow alias:', err);
return c.json({ error: 'Email forwarding service unavailable' }, 503);
}
});
/**
* POST /api/account/email-forward/disable — remove forwarding alias
* Auth required.
*/
app.post('/api/account/email-forward/disable', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
if (!isMailcowConfigured()) {
return c.json({ error: 'Email forwarding is not configured' }, 503);
}
const userId = claims.sub as string;
const status = await getEmailForwardStatus(userId);
if (!status) return c.json({ error: 'User not found' }, 404);
if (!status.enabled) {
return c.json({ error: 'Email forwarding is not enabled' }, 400);
}
// Best-effort: clear local state even if Mailcow delete fails
if (status.mailcowId) {
try {
await deleteAlias(status.mailcowId);
} catch (err) {
console.error('EncryptID: Failed to delete Mailcow alias (clearing local state anyway):', err);
}
}
await setEmailForward(userId, false, null);
return c.json({ success: true, enabled: false });
});
/**
* POST /api/account/device/start — get WebAuthn options for registering another passkey
* Auth required
*/
app.post('/api/account/device/start', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const user = await getUserById(claims.sub as string);
if (!user) return c.json({ error: 'User not found' }, 404);
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
await storeChallenge({
challenge,
userId: claims.sub as string,
type: 'device_registration',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000,
});
const rpId = resolveRpId(c);
const options = {
challenge,
rp: { id: rpId, name: CONFIG.rpName },
user: {
id: claims.sub as string,
name: user.username,
displayName: user.username,
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
timeout: 60000,
attestation: 'none',
};
return c.json({ options, userId: claims.sub });
});
/**
* POST /api/account/device/complete — register additional passkey for existing account
* Body: { challenge, credential: { credentialId, publicKey, transports } }
* Auth required
*/
app.post('/api/account/device/complete', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { challenge, credential } = await c.req.json();
if (!challenge || !credential?.credentialId) {
return c.json({ error: 'Challenge and credential required' }, 400);
}
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'device_registration') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (challengeRecord.userId !== (claims.sub as string)) {
return c.json({ error: 'Challenge mismatch' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
await deleteChallenge(challenge);
const user = await getUserById(claims.sub as string);
if (!user) return c.json({ error: 'User not found' }, 404);
const rpId = resolveRpId(c);
await storeCredential({
credentialId: credential.credentialId,
publicKey: credential.publicKey || '',
userId: claims.sub as string,
username: user.username,
counter: 0,
createdAt: Date.now(),
transports: credential.transports || [],
rpId,
});
console.log('EncryptID: Additional device registered for', user.username);
return c.json({ success: true });
});
// ============================================================================
// RECOVERY ENDPOINTS
// ============================================================================
/**
* Set recovery email for authenticated user
*/
app.post('/api/recovery/email/set', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
const { email } = await c.req.json();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
await setUserEmail(payload.sub as string, email);
return c.json({ success: true, email });
} catch {
return c.json({ error: 'Unauthorized' }, 401);
}
});
/**
* Request account recovery via email — sends a recovery token
*/
app.post('/api/recovery/email/request', async (c) => {
const { email } = await c.req.json();
if (!email) {
return c.json({ error: 'Email required' }, 400);
}
const user = await getUserByEmail(email);
// Always return success to avoid email enumeration
if (!user) {
return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
}
// Generate recovery token
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
const rt: StoredRecoveryToken = {
token,
userId: user.id,
type: 'account_recovery',
createdAt: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes
used: false,
};
await storeRecoveryToken(rt);
// Send recovery email via Mailcow SMTP
try {
await sendRecoveryEmail(email, token, user.username);
} catch (err) {
console.error('EncryptID: Failed to send recovery email:', err);
}
return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
});
/**
* Verify recovery token — returns temporary auth to register a new passkey
*/
app.post('/api/recovery/email/verify', async (c) => {
const { token: recoveryToken } = await c.req.json();
if (!recoveryToken) {
return c.json({ error: 'Recovery token required' }, 400);
}
const rt = await getRecoveryToken(recoveryToken);
if (!rt || rt.used || Date.now() > rt.expiresAt) {
return c.json({ error: 'Invalid or expired recovery token' }, 400);
}
await markRecoveryTokenUsed(recoveryToken);
// Get user info
const user = await getUserById(rt.userId);
if (!user) {
return c.json({ error: 'Account not found' }, 404);
}
// Issue a short-lived recovery session token
const sessionToken = await generateSessionToken(rt.userId, user.username);
return c.json({
success: true,
token: sessionToken,
userId: rt.userId,
username: user.username,
did: user.did,
message: 'Recovery successful. Use this token to register a new passkey.',
});
});
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
async function generateSessionToken(userId: string, username: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
// Check if user has set a wallet address → enable wallet capability
// Safe associations are stored client-side (privacy); server only knows
// the user has wallet capability via the wallet_address profile field.
const profile = await getUserProfile(userId);
const hasWallet = !!profile?.walletAddress;
const payload = {
iss: 'https://auth.rspace.online',
sub: userId,
aud: CONFIG.allowedOrigins,
iat: now,
exp: now + CONFIG.sessionDuration,
username,
did: `did:key:${userId.slice(0, 32)}`,
eid: {
authLevel: 3, // ELEVATED (fresh WebAuthn)
capabilities: {
encrypt: true,
sign: true,
wallet: hasWallet,
},
},
};
return sign(payload, CONFIG.jwtSecret);
}
// ============================================================================
// RECOVERY PAGE
// ============================================================================
app.get('/recover', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rStack Identity — Account Recovery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 2.5rem;
max-width: 440px;
width: 100%;
text-align: center;
}
.logo { font-size: 3rem; margin-bottom: 0.5rem; }
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn {
display: inline-block; padding: 0.75rem 2rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
color: #fff; border: none; border-radius: 8px;
font-weight: 600; font-size: 1rem; cursor: pointer;
transition: transform 0.2s;
}
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="logo">&#128274;</div>
<h1>Account Recovery</h1>
<p class="subtitle">Verify your recovery link and add a new passkey to your rStack account</p>
<div id="status" class="status loading">Verifying recovery token...</div>
<div id="register-section" class="hidden">
<button id="register-btn" class="btn" onclick="registerNewPasskey()">
Add New Passkey
</button>
</div>
<div id="success-section" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;">You can now sign in with your new passkey on any r*.online app.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const recoveryToken = params.get('token');
let sessionToken = null;
let userId = null;
let username = null;
const statusEl = document.getElementById('status');
const registerSection = document.getElementById('register-section');
const successSection = document.getElementById('success-section');
async function verifyToken() {
if (!recoveryToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No recovery token found in link.';
return;
}
try {
const res = await fetch('/api/recovery/email/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: recoveryToken }),
});
const data = await res.json();
if (!data.success) {
statusEl.className = 'status error';
statusEl.textContent = data.error || 'Invalid or expired recovery link.';
return;
}
sessionToken = data.token;
userId = data.userId;
username = data.username;
statusEl.className = 'status success';
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
registerSection.classList.remove('hidden');
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to verify recovery token.';
}
}
async function registerNewPasskey() {
const btn = document.getElementById('register-btn');
btn.disabled = true;
btn.textContent = 'Registering...';
try {
// Start registration
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
body: JSON.stringify({ username, displayName: username }),
});
const { options } = await startRes.json();
// Decode challenge for WebAuthn
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
// WebAuthn ceremony
const credential = await navigator.credentials.create({ publicKey: options });
const credentialData = {
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
transports: credential.response.getTransports?.() || [],
};
// Complete registration
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
});
const result = await completeRes.json();
if (result.success) {
statusEl.className = 'status success';
statusEl.textContent = 'New passkey registered successfully!';
registerSection.classList.add('hidden');
successSection.classList.remove('hidden');
} else {
throw new Error(result.error || 'Registration failed');
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to register passkey: ' + e.message;
btn.disabled = false;
btn.textContent = 'Add New Passkey';
}
}
verifyToken();
</script>
</body>
</html>
`);
});
// ============================================================================
// GUARDIAN MANAGEMENT ROUTES
// ============================================================================
function generateToken(): string {
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
}
/**
* GET /api/guardians — list my guardians
*/
app.get('/api/guardians', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const guardians = await getGuardians(claims.sub as string);
return c.json({
guardians: guardians.map(g => ({
id: g.id,
name: g.name,
email: g.email,
status: g.status,
acceptedAt: g.acceptedAt,
createdAt: g.createdAt,
})),
count: guardians.length,
threshold: 2,
});
});
/**
* POST /api/guardians — add a guardian (max 3 active)
* Body: { name, email? }
* Returns guardian with invite link/token
*/
app.post('/api/guardians', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const { name, email } = await c.req.json();
if (!name) return c.json({ error: 'Guardian name is required' }, 400);
// Check max 3 active guardians
const existing = await getGuardians(userId);
if (existing.length >= 3) {
return c.json({ error: 'Maximum 3 guardians allowed. Remove one first.' }, 400);
}
const id = generateToken();
const inviteToken = generateToken();
const inviteExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
const guardian = await addGuardian(id, userId, name, email || null, inviteToken, inviteExpiresAt);
const inviteUrl = `https://auth.rspace.online/guardian?token=${inviteToken}`;
// Send invite email if email provided
if (email && smtpTransport) {
const user = await getUserById(userId);
const username = user?.username || 'Someone';
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: email,
subject: `${username} wants you as a recovery guardian — rStack`,
text: [
`Hi ${name},`,
'',
`${username} has asked you to be a recovery guardian for their rStack Identity account.`,
'',
'As a guardian, you can help them recover their account if they lose access to their devices.',
'You will never have access to their account or data — only the ability to approve recovery.',
'',
'Accept the invitation:',
inviteUrl,
'',
'This invitation expires in 7 days.',
'',
'— rStack Identity',
].join('\n'),
html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#129309;</div>
<h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Guardian Invitation</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${name}</strong>,</p>
<p><strong>${username}</strong> has asked you to be a <strong>recovery guardian</strong> for their rStack Identity account.</p>
<p style="color:#94a3b8;font-size:13px;">As a guardian, you can help them recover their account if they lose access. You will never have access to their account or data.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${inviteUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Accept Invitation</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This invitation expires in <strong>7 days</strong>.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${inviteUrl}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} catch (err) {
console.error('EncryptID: Failed to send guardian invite email:', err);
}
}
return c.json({
guardian: {
id: guardian.id,
name: guardian.name,
email: guardian.email,
status: guardian.status,
},
inviteUrl,
inviteToken,
}, 201);
});
/**
* DELETE /api/guardians/:id — remove a guardian
*/
app.delete('/api/guardians/:id', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const { id } = c.req.param();
const removed = await removeGuardian(id, claims.sub as string);
if (!removed) return c.json({ error: 'Guardian not found' }, 404);
return c.json({ success: true });
});
/**
* GET /api/guardian/invites — list accounts I'm a guardian for
*/
app.get('/api/guardian/invites', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const guardianships = await getGuardianships(claims.sub as string);
const result = [];
for (const g of guardianships) {
const owner = await getUserById(g.userId);
result.push({
id: g.id,
ownerUsername: owner?.username || 'Unknown',
name: g.name,
acceptedAt: g.acceptedAt,
});
}
return c.json({ guardianships: result });
});
/**
* GET /api/guardian/requests — pending recovery requests where I'm a guardian
*/
app.get('/api/guardian/requests', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const guardianships = await getGuardianships(claims.sub as string);
const pending = [];
for (const g of guardianships) {
const req = await getActiveRecoveryRequest(g.userId);
if (req) {
const owner = await getUserById(g.userId);
const approvals = await getRecoveryApprovals(req.id);
const myApproval = approvals.find(a => a.guardianId === g.id);
pending.push({
requestId: req.id,
ownerUsername: owner?.username || 'Unknown',
status: req.status,
threshold: req.threshold,
approvalCount: req.approvalCount,
initiatedAt: req.initiatedAt,
expiresAt: req.expiresAt,
alreadyApproved: !!myApproval?.approvedAt,
guardianId: g.id,
});
}
}
return c.json({ requests: pending });
});
// ============================================================================
// GUARDIAN INVITE ACCEPTANCE PAGE
// ============================================================================
app.get('/guardian', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guardian Invitation — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.info-box { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; text-align: left; font-size: 0.85rem; color: #e2e8f0; line-height: 1.6; }
.info-box strong { color: #00d4ff; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-outline { background: transparent; border: 1px solid rgba(255,255,255,0.2); margin-left: 0.5rem; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#129309;</div>
<h1>Guardian Invitation</h1>
<div id="status" class="status loading">Loading invitation...</div>
<div id="info" class="hidden">
<div class="info-box">
<p><strong id="owner-name">Someone</strong> wants you as a recovery guardian.</p>
<p style="margin-top:0.5rem;color:#94a3b8;">If they ever lose access to their devices, you'll be asked to approve their account recovery. You won't have access to their account or data.</p>
</div>
<div id="accept-section">
<p style="font-size:0.85rem;color:#94a3b8;margin-bottom:1rem;">To accept, sign in with your passkey (or create an account).</p>
<button class="btn" id="accept-btn" onclick="acceptInvite()">Accept & Sign In</button>
</div>
</div>
<div id="done" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You're now a guardian. If <strong id="done-owner">they</strong> ever need recovery, you'll receive a notification.</p>
</div>
</div>
<script type="module">
import { bufferToBase64url, base64urlToBuffer } 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.';
}
}
// Server-initiated auth: /api/auth/start → WebAuthn get → /api/auth/complete
async function serverAuth() {
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
},
});
if (!credential) throw new Error('Authentication failed');
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || 'Authentication failed');
return data;
}
// Server-initiated registration: /api/register/start → WebAuthn create → /api/register/complete
async function serverRegister(username) {
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: serverOptions.challenge, credential: credentialData, userId, username }),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || 'Registration failed');
return data;
}
async function acceptWithToken(token) {
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + 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;
}
window.acceptInvite = async () => {
const btn = document.getElementById('accept-btn');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
let token;
try {
// Try to authenticate with existing passkey
const authData = await serverAuth();
token = authData.token;
} 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; }
const regData = await serverRegister(username);
token = regData.token;
}
localStorage.setItem('encryptid_token', token);
await acceptWithToken(token);
} 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);
// Notify the account owner that their guardian accepted
const acceptorUser = await getUserById(claims.sub as string);
notify({
userDid: guardian.userId,
category: 'system',
eventType: 'guardian_accepted',
title: `${acceptorUser?.username || guardian.name} accepted your guardian invite`,
body: `${guardian.name} is now a recovery guardian for your account.`,
actorDid: claims.sub as string,
actorUsername: acceptorUser?.username || guardian.name,
metadata: { guardianId: guardian.id },
}).catch(() => {});
return c.json({ success: true, message: 'You are now a guardian!' });
});
// ============================================================================
// SOCIAL RECOVERY ROUTES
// ============================================================================
/**
* POST /api/recovery/social/initiate — start social recovery
* Body: { email or username }
* Sends approval links to all guardians
*/
app.post('/api/recovery/social/initiate', async (c) => {
const { email, username: reqUsername } = await c.req.json();
// Find the user
let user;
if (email) user = await getUserByEmail(email);
if (!user && reqUsername) user = await getUserByUsername(reqUsername);
// Always return success to prevent enumeration
if (!user) return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
const guardians = await getGuardians(user.id);
const accepted = guardians.filter(g => g.status === 'accepted');
if (accepted.length < 2) {
return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
}
// Check for existing active request
const existing = await getActiveRecoveryRequest(user.id);
if (existing) {
return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
}
// Create recovery request (7 day expiry, 2-of-3 threshold)
const requestId = generateToken();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
await createRecoveryRequest(requestId, user.id, 2, expiresAt);
// Notify account owner about the recovery initiation
notify({
userDid: user.id,
category: 'system',
eventType: 'recovery_initiated',
title: 'Account recovery initiated',
body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
}).catch(() => {});
// Create approval tokens and notify guardians
for (const guardian of accepted) {
const approvalToken = generateToken();
await createRecoveryApproval(requestId, guardian.id, approvalToken);
const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`;
// In-app notification for guardians with rSpace accounts
if (guardian.guardianUserId) {
notify({
userDid: guardian.guardianUserId,
category: 'system',
eventType: 'recovery_initiated',
title: `${user.username} needs your help to recover their account`,
body: 'Review and approve the recovery request if legitimate.',
actionUrl: approveUrl,
actorUsername: user.username,
metadata: { recoveryRequestId: requestId, guardianId: guardian.id },
}).catch(() => {});
}
// Send email if available
if (guardian.email && smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: guardian.email,
subject: `Recovery request from ${user.username} — rStack`,
text: [
`Hi ${guardian.name},`,
'',
`${user.username} is trying to recover their rStack Identity account and needs your help.`,
'',
'To approve this recovery, click the link below:',
approveUrl,
'',
'This request expires in 7 days. 2 of 3 guardians must approve.',
'',
'If you did not expect this, do NOT click the link.',
'',
'— rStack Identity',
].join('\n'),
html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
<tr><td style="padding:32px 32px 24px;text-align:center;">
<div style="font-size:36px;margin-bottom:8px;">&#128272;</div>
<h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Recovery Request</h1>
</td></tr>
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
<p>Hi <strong>${guardian.name}</strong>,</p>
<p><strong>${user.username}</strong> is trying to recover their rStack Identity account and needs your approval.</p>
<p style="color:#94a3b8;font-size:13px;">2 of 3 guardians must approve for recovery to proceed.</p>
</td></tr>
<tr><td style="padding:0 32px 32px;text-align:center;">
<a href="${approveUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#22c55e,#16a34a);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Approve Recovery</a>
</td></tr>
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
<p>This request expires in <strong>7 days</strong>.</p>
<p style="color:#ef4444;margin-top:8px;">If you did not expect this request, do NOT click the button.</p>
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
Can't click the button? Copy this link:<br>
<span style="color:#94a3b8;word-break:break-all;">${approveUrl}</span>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
});
} catch (err) {
console.error(`EncryptID: Failed to send recovery approval email to ${guardian.email}:`, err);
}
}
}
return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
});
/**
* GET /approve — one-click guardian approval page
*/
app.get('/approve', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Approve Recovery — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128272;</div>
<h1>Recovery Approval</h1>
<div id="status" class="status loading">Verifying approval link...</div>
<button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const approvalToken = params.get('token');
const statusEl = document.getElementById('status');
const btn = document.getElementById('approve-btn');
async function init() {
if (!approvalToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No approval token found in link.';
return;
}
// Show confirmation step
statusEl.className = 'status warning';
statusEl.textContent = 'You are about to approve a recovery request. Only do this if the account owner contacted you directly and asked for help recovering their account.';
btn.classList.remove('hidden');
}
async function doApprove() {
btn.disabled = true;
btn.textContent = 'Approving...';
try {
const res = await fetch('/api/recovery/social/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvalToken }),
});
const data = await res.json();
if (data.error) {
statusEl.className = 'status error';
statusEl.textContent = data.error;
btn.classList.add('hidden');
return;
}
statusEl.className = 'status success';
statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
(data.approvalCount >= data.threshold ? '<br><br>The account owner can now recover their account.' : '<br><br>Waiting for more guardians to approve.');
btn.classList.add('hidden');
} catch {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to process approval.';
btn.disabled = false;
btn.textContent = 'Confirm Approval';
}
}
init();
</script>
</body>
</html>
`);
});
/**
* POST /api/recovery/social/approve — approve via token (no auth needed, token is proof)
*/
app.post('/api/recovery/social/approve', async (c) => {
const { approvalToken } = await c.req.json();
if (!approvalToken) return c.json({ error: 'Approval token required' }, 400);
const result = await approveRecoveryByToken(approvalToken);
if (!result) return c.json({ error: 'Invalid or already used approval link' }, 400);
// Get updated request
const request = await getRecoveryRequest(result.requestId);
if (!request) return c.json({ error: 'Recovery request not found' }, 404);
// Check if threshold met
if (request.approvalCount >= request.threshold && request.status === 'pending') {
await updateRecoveryRequestStatus(request.id, 'approved');
// Notify account owner that recovery is approved
notify({
userDid: request.userId,
category: 'system',
eventType: 'recovery_approved',
title: 'Account recovery approved',
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
metadata: { recoveryRequestId: request.id },
}).catch(() => {});
}
return c.json({
success: true,
approvalCount: request.approvalCount,
threshold: request.threshold,
status: request.approvalCount >= request.threshold ? 'approved' : 'pending',
});
});
/**
* GET /api/recovery/social/:id/status — check recovery request status
*/
app.get('/api/recovery/social/:id/status', async (c) => {
const { id } = c.req.param();
const request = await getRecoveryRequest(id);
if (!request) return c.json({ error: 'Not found' }, 404);
const approvals = await getRecoveryApprovals(request.id);
return c.json({
id: request.id,
status: request.status,
threshold: request.threshold,
approvalCount: request.approvalCount,
approvals: approvals.map(a => ({
guardianId: a.guardianId,
approved: !!a.approvedAt,
})),
expiresAt: request.expiresAt,
});
});
/**
* POST /api/recovery/social/:id/complete — finalize recovery (register new passkey)
* Only works when status is 'approved'
*/
app.post('/api/recovery/social/:id/complete', async (c) => {
const { id } = c.req.param();
const request = await getRecoveryRequest(id);
if (!request) return c.json({ error: 'Not found' }, 404);
if (request.status !== 'approved') return c.json({ error: 'Recovery not yet approved. ' + request.approvalCount + '/' + request.threshold + ' approvals.' }, 400);
if (Date.now() > request.expiresAt) {
await updateRecoveryRequestStatus(id, 'expired');
return c.json({ error: 'Recovery request expired' }, 400);
}
// Mark completed and issue a recovery session token
await updateRecoveryRequestStatus(id, 'completed');
const user = await getUserById(request.userId);
if (!user) return c.json({ error: 'User not found' }, 404);
const token = await generateSessionToken(request.userId, user.username);
return c.json({
success: true,
token,
userId: request.userId,
username: user.username,
message: 'Recovery complete. Use this token to register a new passkey.',
});
});
// ============================================================================
// DEVICE LINKING ROUTES
// ============================================================================
/**
* POST /api/device-link/start — generate a device link token (authenticated)
*/
app.post('/api/device-link/start', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const token = generateToken();
const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes
await createDeviceLink(token, claims.sub as string, expiresAt);
const linkUrl = `https://auth.rspace.online/link?token=${token}`;
return c.json({ token, linkUrl, expiresAt });
});
/**
* GET /api/device-link/:token/info — get link info (unauthenticated)
*/
app.get('/api/device-link/:token/info', async (c) => {
const { token } = c.req.param();
const link = await getDeviceLink(token);
if (!link) return c.json({ error: 'Invalid link' }, 404);
if (link.used) return c.json({ error: 'Link already used' }, 400);
if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400);
const user = await getUserById(link.userId);
return c.json({
username: user?.username || 'Unknown',
expiresAt: link.expiresAt,
});
});
/**
* POST /api/device-link/:token/complete — register new credential on linked device
*/
app.post('/api/device-link/:token/complete', async (c) => {
const { token } = c.req.param();
const link = await getDeviceLink(token);
if (!link) return c.json({ error: 'Invalid link' }, 404);
if (link.used) return c.json({ error: 'Link already used' }, 400);
if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400);
const { credential } = await c.req.json();
if (!credential?.credentialId || !credential?.publicKey) {
return c.json({ error: 'Credential data required' }, 400);
}
const user = await getUserById(link.userId);
if (!user) return c.json({ error: 'User not found' }, 404);
// Store the new credential under the same user
const rpId = resolveRpId(c);
await storeCredential({
credentialId: credential.credentialId,
publicKey: credential.publicKey,
userId: link.userId,
username: user.username,
counter: 0,
createdAt: Date.now(),
transports: credential.transports,
rpId,
});
await markDeviceLinkUsed(token);
console.log('EncryptID: Second device linked for', user.username);
return c.json({ success: true, message: 'Device linked successfully' });
});
/**
* GET /link — device linking page (scanned from QR or opened from email)
*/
app.get('/link', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Device — rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128241;</div>
<h1>Link This Device</h1>
<div id="status" class="status loading">Loading...</div>
<button id="link-btn" class="btn hidden" onclick="linkDevice()">Create Passkey on This Device</button>
<div id="done" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You can now sign in from this device using your passkey.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const linkToken = params.get('token');
const statusEl = document.getElementById('status');
const btn = document.getElementById('link-btn');
let linkInfo = null;
async function init() {
if (!linkToken) {
statusEl.className = 'status error';
statusEl.textContent = 'No link token found.';
return;
}
try {
const res = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/info');
const data = await res.json();
if (data.error) {
statusEl.className = 'status error';
statusEl.textContent = data.error;
return;
}
linkInfo = data;
statusEl.className = 'status success';
statusEl.textContent = 'Link this device to ' + data.username + String.fromCharCode(39) + '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>
`);
});
// ============================================================================
// SHARED AUTH HELPER
// ============================================================================
// Helper: verify JWT and return claims, or null
async function verifyTokenFromRequest(authorization: string | undefined): Promise<{
sub: string; did?: string; username?: string; eid?: any;
} | null> {
if (!authorization?.startsWith('Bearer ')) return null;
const token = authorization.slice(7);
try {
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
return payload as { sub: string; did?: string; username?: string; eid?: any };
} catch {
return null;
}
}
// ============================================================================
// WALLET CAPABILITY ROUTES
// ============================================================================
// POST /encryptid/api/wallet-capability — Enable wallet capability for this user.
// Sets wallet_address on the user profile. Safe associations are stored
// client-side only (encrypted localStorage) to avoid server-side correlation
// between identity and on-chain addresses.
app.post('/encryptid/api/wallet-capability', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const { walletAddress } = await c.req.json();
if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
return c.json({ error: 'Valid Ethereum address required' }, 400);
}
await updateUserProfile(claims.sub, { walletAddress });
return c.json({ enabled: true, walletAddress });
});
// DELETE /encryptid/api/wallet-capability — Disable wallet capability.
app.delete('/encryptid/api/wallet-capability', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
await updateUserProfile(claims.sub, { walletAddress: null });
return c.json({ enabled: false });
});
// POST /encryptid/api/safe/verify — Check if EOA is owner on a Safe (stateless proxy).
// No server-side state — just proxies Safe Transaction Service API.
app.post('/encryptid/api/safe/verify', async (c) => {
const { safeAddress, chainId, eoaAddress } = await c.req.json();
if (!safeAddress || !eoaAddress) {
return c.json({ error: 'safeAddress and eoaAddress are required' }, 400);
}
const chain = chainId || 84532;
const CHAIN_PREFIXES: Record<number, string> = {
1: 'eth', 10: 'oeth', 100: 'gno', 137: 'pol',
8453: 'base', 42161: 'arb1', 42220: 'celo', 43114: 'avax',
56: 'bnb', 324: 'zksync', 11155111: 'sep', 84532: 'basesep',
};
const prefix = CHAIN_PREFIXES[chain];
if (!prefix) {
return c.json({ error: 'Unsupported chain' }, 400);
}
try {
const safeRes = await fetch(
`https://api.safe.global/tx-service/${prefix}/api/v1/safes/${safeAddress}/`,
);
if (!safeRes.ok) {
return c.json({ isOwner: false, error: 'Safe not found' });
}
const safeData = await safeRes.json() as { owners?: string[]; threshold?: number; nonce?: number };
const owners = (safeData.owners || []).map((o: string) => o.toLowerCase());
const isOwner = owners.includes(eoaAddress.toLowerCase());
return c.json({
isOwner,
safeAddress,
chainId: chain,
threshold: safeData.threshold,
ownerCount: owners.length,
nonce: safeData.nonce,
});
} catch {
return c.json({ isOwner: false, error: 'Failed to query Safe' }, 502);
}
});
// ============================================================================
// SPACE MEMBERSHIP ROUTES
// ============================================================================
const VALID_ROLES = ['viewer', 'member', 'moderator', 'admin'];
// GET /api/spaces/:slug/members — list all members
app.get('/api/spaces/:slug/members', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) {
return c.json({ error: 'Authentication required' }, 401);
}
const members = await listSpaceMembers(slug);
return c.json({
members: members.map((m) => ({
userDID: m.userDID,
spaceSlug: m.spaceSlug,
role: m.role,
joinedAt: m.joinedAt,
grantedBy: m.grantedBy,
})),
total: members.length,
});
});
// GET /api/spaces/:slug/members/:did — get one member's role
app.get('/api/spaces/:slug/members/:did', async (c) => {
const { slug, did } = c.req.param();
const member = await getSpaceMember(slug, decodeURIComponent(did));
if (!member) {
return c.json({ error: 'Member not found' }, 404);
}
return c.json({
userDID: member.userDID,
spaceSlug: member.spaceSlug,
role: member.role,
joinedAt: member.joinedAt,
grantedBy: member.grantedBy,
});
});
// POST /api/spaces/:slug/members — add or update a member
app.post('/api/spaces/:slug/members', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) {
return c.json({ error: 'Authentication required' }, 401);
}
// Verify caller is admin in this space (or first member → becomes admin)
const callerMember = await getSpaceMember(slug, claims.sub);
const members = await listSpaceMembers(slug);
// If space has no members, the first person to add becomes admin
const isFirstMember = members.length === 0;
if (!isFirstMember && (!callerMember || callerMember.role !== 'admin')) {
return c.json({ error: 'Admin role required' }, 403);
}
const body = await c.req.json<{ userDID: string; role: string }>();
if (!body.userDID || !body.role) {
return c.json({ error: 'userDID and role are required' }, 400);
}
if (!VALID_ROLES.includes(body.role)) {
return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
}
const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub);
return c.json({
userDID: member.userDID,
spaceSlug: member.spaceSlug,
role: member.role,
joinedAt: member.joinedAt,
grantedBy: member.grantedBy,
}, 201);
});
// DELETE /api/spaces/:slug/members/:did — remove a member
app.delete('/api/spaces/:slug/members/:did', async (c) => {
const { slug, did } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) {
return c.json({ error: 'Authentication required' }, 401);
}
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const removed = await removeSpaceMember(slug, decodeURIComponent(did));
if (!removed) {
return c.json({ error: 'Member not found' }, 404);
}
return c.json({ success: true });
});
// ============================================================================
// USER LOOKUP
// ============================================================================
// GET /api/users/lookup?username=<username> — look up user by username
app.get('/api/users/lookup', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const username = c.req.query('username');
if (!username) return c.json({ error: 'username query parameter required' }, 400);
const user = await getUserByUsername(username);
if (!user) return c.json({ error: 'User not found' }, 404);
return c.json({
did: user.did,
username: user.username,
displayName: user.display_name || user.username,
});
});
// ============================================================================
// SPACE INVITE ROUTES
// ============================================================================
// POST /api/spaces/:slug/invites — create an invite
app.post('/api/spaces/:slug/invites', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
// Require admin role
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const body = await c.req.json<{ email?: string; role?: string }>();
const role = body.role || 'member';
if (!VALID_ROLES.includes(role)) {
return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
}
const id = crypto.randomUUID();
const token = crypto.randomUUID();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
const invite = await createSpaceInvite(id, slug, token, claims.sub, role, expiresAt, body.email || undefined);
return c.json(invite, 201);
});
// GET /api/spaces/:slug/invites — list invites for a space
app.get('/api/spaces/:slug/invites', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const invites = await listSpaceInvites(slug);
return c.json({ invites, total: invites.length });
});
// POST /api/spaces/:slug/invites/:id/revoke — revoke an invite
app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => {
const { slug, id } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const revoked = await revokeSpaceInvite(id, slug);
if (!revoked) return c.json({ error: 'Invite not found or already used' }, 404);
return c.json({ ok: true });
});
// GET /api/invites/:token — get invite details by token (public, no auth needed)
app.get('/api/invites/:token', async (c) => {
const { token } = c.req.param();
const invite = await getSpaceInviteByToken(token);
if (!invite) return c.json({ error: 'Invite not found' }, 404);
if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);
return c.json({
spaceSlug: invite.spaceSlug,
role: invite.role,
expiresAt: invite.expiresAt,
});
});
// POST /api/invites/:token/accept — accept an invite
app.post('/api/invites/:token/accept', async (c) => {
const { token } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required — sign in to accept' }, 401);
const invite = await getSpaceInviteByToken(token);
if (!invite) return c.json({ error: 'Invite not found' }, 404);
if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);
// Accept the invite
const accepted = await acceptSpaceInvite(token, claims.sub);
if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);
// Add to space_members with the invite's role
await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy);
return c.json({
ok: true,
spaceSlug: accepted.spaceSlug,
role: accepted.role,
});
});
// ============================================================================
// ADMIN ROUTES
// ============================================================================
function isAdmin(did: string | undefined): boolean {
if (!did || CONFIG.adminDIDs.length === 0) return false;
return CONFIG.adminDIDs.includes(did);
}
// GET /api/admin/users — list all users (admin only)
app.get('/api/admin/users', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const users = await listAllUsers();
return c.json({ users, total: users.length });
});
// DELETE /api/admin/users/:userId — delete a user (admin only)
app.delete('/api/admin/users/:userId', async (c) => {
const userId = c.req.param('userId');
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
if (userId === claims.sub) {
return c.json({ error: 'Cannot delete your own account' }, 400);
}
const deleted = await deleteUser(userId);
if (!deleted) return c.json({ error: 'User not found' }, 404);
return c.json({ ok: true, message: `User ${userId} deleted` });
});
// DELETE /api/admin/spaces/:slug/members — clean up space members (admin only)
app.delete('/api/admin/spaces/:slug/members', async (c) => {
const slug = c.req.param('slug');
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const count = await deleteSpaceMembers(slug);
return c.json({ ok: true, removed: count });
});
// ============================================================================
// FUND CLAIM ROUTES
// ============================================================================
const INTERNAL_SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
// POST /api/internal/fund-claims — called by rflows to create a claim
app.post('/api/internal/fund-claims', async (c) => {
const serviceKey = c.req.header('X-Service-Key');
if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { email, walletAddress, openfortPlayerId, fiatAmount, fiatCurrency, sessionId, provider } = await c.req.json();
if (!email || !walletAddress) {
return c.json({ error: 'email and walletAddress are required' }, 400);
}
const emailHashed = await hashEmail(email);
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
// Check for existing pending claim — accumulate deposits
const existingClaims = await getFundClaimsByEmailHash(emailHashed);
if (existingClaims.length > 0 && fiatAmount) {
const existing = existingClaims[0];
const updated = await accumulateFundClaim(existing.id, fiatAmount, expiresAt);
let sent = false;
try {
const totalAmount = updated?.fiatAmount || fiatAmount;
sent = await sendClaimEmail(email, existing.token, totalAmount, fiatCurrency || 'USD');
} catch (err) {
console.error('EncryptID: Failed to send claim email:', err);
}
return c.json({ claimId: existing.id, accumulated: true, sent });
}
// No existing claim — create new
const id = crypto.randomUUID();
const token = generateToken();
const claim = await createFundClaim({
id,
token,
emailHash: emailHashed,
email,
walletAddress,
openfortPlayerId,
fiatAmount,
fiatCurrency: fiatCurrency || 'USD',
sessionId,
provider,
expiresAt,
});
let sent = false;
try {
sent = await sendClaimEmail(email, token, fiatAmount, fiatCurrency || 'USD');
} catch (err) {
console.error('EncryptID: Failed to send claim email:', err);
}
return c.json({ claimId: claim.id, sent });
});
// GET /api/claims/:token — public claim info (no auth)
app.get('/api/claims/:token', async (c) => {
const token = c.req.param('token');
const claim = await getFundClaimByToken(token);
if (!claim) return c.json({ error: 'Claim not found' }, 404);
const truncatedWallet = `${claim.walletAddress.slice(0, 6)}...${claim.walletAddress.slice(-4)}`;
return c.json({
status: claim.status,
amount: claim.fiatAmount,
currency: claim.fiatCurrency,
truncatedWallet,
expiresAt: claim.expiresAt,
expired: claim.expiresAt < Date.now(),
});
});
// POST /api/claims/:token/accept — authenticated claim acceptance
app.post('/api/claims/:token/accept', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const token = c.req.param('token');
const fundClaim = await getFundClaimByToken(token);
if (!fundClaim) return c.json({ error: 'Claim not found' }, 404);
if (fundClaim.status !== 'pending' && fundClaim.status !== 'resent') return c.json({ error: 'Claim already processed' }, 400);
if (fundClaim.expiresAt < Date.now()) return c.json({ error: 'Claim expired' }, 400);
// Link wallet to user profile
await updateUserProfile(claims.sub, { walletAddress: fundClaim.walletAddress });
// Mark claim as claimed
const accepted = await acceptFundClaim(token, claims.sub);
if (!accepted) return c.json({ error: 'Failed to accept claim' }, 500);
// Get username for redirect URL
const user = await getUserById(claims.sub);
return c.json({
success: true,
walletAddress: fundClaim.walletAddress,
amount: fundClaim.fiatAmount,
currency: fundClaim.fiatCurrency,
username: user?.username || '',
});
});
// POST /api/claims/resend — resend claim email (anti-enumeration: always 200)
app.post('/api/claims/resend', async (c) => {
const { email } = await c.req.json();
if (!email) return c.json({ ok: true });
const emailHashed = await hashEmail(email);
const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
if (pendingClaims.length > 0) {
const claim = pendingClaims[0];
// Generate new token for the resend
const newToken = generateToken();
await sql`UPDATE fund_claims SET token = ${newToken}, status = 'resent' WHERE id = ${claim.id}`;
try {
await sendClaimEmail(email, newToken, claim.fiatAmount || undefined, claim.fiatCurrency);
} catch (err) {
console.error('EncryptID: Failed to resend claim email:', err);
}
}
// Always return ok (anti-enumeration)
return c.json({ ok: true });
});
// GET /claim — self-contained claim page
app.get('/claim', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rStack — Claim Your Funds</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;
padding: 20px;
}
.card {
background: #16213e;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 40px;
max-width: 440px;
width: 100%;
text-align: center;
}
.icon { font-size: 48px; margin-bottom: 16px; }
h1 {
font-size: 24px;
margin-bottom: 8px;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle { color: #94a3b8; font-size: 14px; margin-bottom: 24px; }
.info-row {
display: flex;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
margin-bottom: 8px;
color: #e2e8f0;
font-size: 14px;
}
.info-label { color: #94a3b8; }
.info-value { font-weight: 600; font-family: monospace; }
.btn {
display: inline-block;
padding: 14px 32px;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
color: #fff;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 15px;
cursor: pointer;
text-decoration: none;
margin-top: 24px;
width: 100%;
}
.btn:hover { opacity: 0.9; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: rgba(255,255,255,0.1);
margin-top: 12px;
}
.error { color: #f87171; font-size: 14px; margin-top: 16px; }
.success { color: #4ade80; }
.success-card { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 12px; padding: 24px; margin-top: 20px; }
#status { margin-top: 16px; }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128176;</div>
<h1>Claim Your Funds</h1>
<div class="subtitle">Sign in with EncryptID to link your funded wallet</div>
<div id="loading"><div class="spinner"></div><p style="color:#94a3b8;margin-top:12px">Loading claim...</p></div>
<div id="claim-info" class="hidden"></div>
<div id="status"></div>
</div>
<script>
const params = new URLSearchParams(location.search);
const token = params.get('token');
const authBase = location.origin;
function getSession() {
// Check both storage keys (landing page uses encryptid_token, SDK uses encryptid_session)
const token = localStorage.getItem('encryptid_token');
if (token) return { token };
try { return JSON.parse(localStorage.getItem('encryptid_session') || 'null'); } catch { return null; }
}
async function init() {
if (!token) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('status').innerHTML = '<p class="error">No claim token provided.</p>';
return;
}
try {
const res = await fetch(authBase + '/api/claims/' + encodeURIComponent(token));
const data = await res.json();
document.getElementById('loading').classList.add('hidden');
if (!res.ok) {
document.getElementById('status').innerHTML = '<p class="error">' + (data.error || 'Claim not found') + '</p>';
return;
}
if (data.status === 'claimed') {
document.getElementById('claim-info').classList.remove('hidden');
document.getElementById('claim-info').innerHTML =
'<div class="success-card"><p class="success" style="font-size:18px;font-weight:600">&#x2705; Already Claimed</p>' +
'<p style="color:#94a3b8;margin-top:8px">These funds have already been linked to an account.</p></div>';
return;
}
if (data.expired || data.status === 'expired') {
document.getElementById('claim-info').classList.remove('hidden');
document.getElementById('claim-info').innerHTML =
'<p class="error">This claim has expired.</p>';
return;
}
const infoHtml =
(data.amount ? '<div class="info-row"><span class="info-label">Amount</span><span class="info-value">$' + data.amount + ' ' + (data.currency || 'USD') + '</span></div>' : '') +
'<div class="info-row"><span class="info-label">Wallet</span><span class="info-value">' + data.truncatedWallet + '</span></div>' +
'<div class="info-row"><span class="info-label">Expires</span><span class="info-value">' + new Date(data.expiresAt).toLocaleDateString() + '</span></div>';
const session = getSession();
let actionHtml = '';
if (session && session.token) {
actionHtml = '<button class="btn" id="claim-btn">Claim Your Funds</button>';
} else {
const redirect = encodeURIComponent('/claim?token=' + encodeURIComponent(token));
actionHtml =
'<a class="btn" href="/?redirect=' + redirect + '">Sign In to Claim</a>' +
'<a class="btn btn-secondary" href="/?tab=register&redirect=' + redirect + '">Create Account</a>' +
'<p style="color:#64748b;font-size:12px;margin-top:16px">You will be redirected back here after signing in.</p>';
}
document.getElementById('claim-info').classList.remove('hidden');
document.getElementById('claim-info').innerHTML = infoHtml + actionHtml;
const claimBtn = document.getElementById('claim-btn');
if (claimBtn) {
claimBtn.addEventListener('click', async () => {
claimBtn.disabled = true;
claimBtn.textContent = 'Claiming...';
try {
const acceptRes = await fetch(authBase + '/api/claims/' + encodeURIComponent(token) + '/accept', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + session.token },
});
const result = await acceptRes.json();
if (acceptRes.ok && result.success) {
var walletUrl = 'https://' + (result.username || 'demo') + '.rspace.online/rwallet?address=' + encodeURIComponent(result.walletAddress);
document.getElementById('claim-info').innerHTML =
'<div class="success-card">' +
'<p class="success" style="font-size:18px;font-weight:600">&#x2705; Funds Claimed!</p>' +
'<p style="color:#e2e8f0;margin-top:12px">Wallet <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:4px">' + result.walletAddress.slice(0, 6) + '...' + result.walletAddress.slice(-4) + '</code> has been linked to your EncryptID account.</p>' +
(result.amount ? '<p style="color:#94a3b8;margin-top:8px">$' + result.amount + ' ' + (result.currency || 'USD') + ' are now accessible.</p>' : '') +
'<a class="btn" href="' + walletUrl + '" style="margin-top:20px">View in rWallet</a>' +
'</div>';
} else {
document.getElementById('status').innerHTML = '<p class="error">' + (result.error || 'Claim failed') + '</p>';
claimBtn.disabled = false;
claimBtn.textContent = 'Claim Your Funds';
}
} catch (err) {
document.getElementById('status').innerHTML = '<p class="error">Network error. Please try again.</p>';
claimBtn.disabled = false;
claimBtn.textContent = 'Claim Your Funds';
}
});
}
} catch (err) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('status').innerHTML = '<p class="error">Failed to load claim info.</p>';
}
}
init();
</script>
</body>
</html>
`);
});
// ============================================================================
// IDENTITY INVITES ("Claim your rSpace" flow)
// ============================================================================
// Send an invite (authenticated user invites a friend by email)
app.post('/api/invites/identity', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const body = await c.req.json();
const { email, message, spaceSlug, spaceRole } = body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
return c.json({ error: 'Valid email is required' }, 400);
}
// Check if this email already has an account
const existingUser = await getUserByEmail(email.toLowerCase().trim());
if (existingUser) {
return c.json({ error: 'This person already has an EncryptID account' }, 409);
}
const id = crypto.randomUUID();
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');
const invite = await createIdentityInvite({
id,
token,
email: email.toLowerCase().trim(),
invitedByUserId: payload.sub,
invitedByUsername: payload.username,
message: message || undefined,
spaceSlug: spaceSlug || undefined,
spaceRole: spaceRole || 'member',
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Send invite email
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
if (smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: email,
subject: `${payload.username} invited you to join rSpace`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
<h2 style="color: #1a1a2e;">You've been invited to rSpace</h2>
<p><strong>${escapeHtml(payload.username)}</strong> wants you to join the rSpace ecosystem — a suite of privacy-first tools powered by passkey authentication.</p>
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
<p>Click below to claim your identity and set up your passkey:</p>
<p style="text-align: center; margin: 2rem 0;">
<a href="${joinLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Claim your rSpace</a>
</p>
<p style="color: #94a3b8; font-size: 0.85rem;">This invite expires in 7 days. If you didn't expect this, you can safely ignore it.</p>
</div>
`,
});
} catch (err) {
console.error('EncryptID: Failed to send invite email:', (err as Error).message);
}
} else {
console.log(`EncryptID: [NO SMTP] Invite link for ${email}: ${joinLink}`);
}
return c.json({ id: invite.id, token: invite.token, email: invite.email });
});
// List invites sent by the authenticated user
app.get('/api/invites/identity', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const invites = await getIdentityInvitesByInviter(payload.sub);
return c.json({ invites });
});
// Revoke a pending invite
app.delete('/api/invites/identity/:id', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const revoked = await revokeIdentityInvite(c.req.param('id'), payload.sub);
return revoked ? c.json({ success: true }) : c.json({ error: 'Invite not found or already claimed' }, 404);
});
// Get invite info (public — for the claim page)
app.get('/api/invites/identity/:token/info', async (c) => {
const invite = await getIdentityInviteByToken(c.req.param('token'));
if (!invite || invite.status !== 'pending') {
return c.json({ error: 'Invite not found or expired' }, 404);
}
if (Date.now() > invite.expiresAt) {
return c.json({ error: 'Invite expired' }, 410);
}
return c.json({
invitedBy: invite.invitedByUsername,
message: invite.message,
email: invite.email,
spaceSlug: invite.spaceSlug,
});
});
// Claim an invite (after passkey registration)
app.post('/api/invites/identity/:token/claim', async (c) => {
const token = c.req.param('token');
const body = await c.req.json();
const { sessionToken } = body;
// Verify the new user's session token (from registration)
let payload: any;
try {
payload = await verify(sessionToken, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid session' }, 401);
}
const invite = await getIdentityInviteByToken(token);
if (!invite || invite.status !== 'pending') {
return c.json({ error: 'Invite not found or already claimed' }, 404);
}
if (Date.now() > invite.expiresAt) {
return c.json({ error: 'Invite expired' }, 410);
}
// Link the email to the new user's account
await setUserEmail(payload.sub, invite.email);
// Claim the invite
const claimed = await claimIdentityInvite(token, payload.sub);
if (!claimed) {
return c.json({ error: 'Failed to claim invite' }, 500);
}
// Auto-join space if specified
if (invite.spaceSlug) {
const did = `did:key:${payload.sub.slice(0, 32)}`;
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
}
return c.json({ success: true, email: invite.email, spaceSlug: invite.spaceSlug });
});
// Join page — the invite claim UI
app.get('/join', async (c) => {
const token = c.req.query('token');
return c.html(joinPage(token || ''));
});
function joinPage(token: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claim your rSpace — EncryptID</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.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 1rem;
padding: 2.5rem;
max-width: 440px;
width: 90%;
text-align: center;
}
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
.inviter { color: #7c3aed; font-weight: 600; }
.message-box {
border-left: 3px solid #7c3aed;
padding: 0.6rem 1rem;
margin: 1rem 0 1.5rem;
text-align: left;
color: #cbd5e1;
background: rgba(124,58,237,0.08);
border-radius: 0 0.5rem 0.5rem 0;
font-size: 0.9rem;
font-style: italic;
display: none;
}
.form-group { margin-bottom: 1rem; text-align: left; }
.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;
}
.form-group input:focus { border-color: #7c3aed; }
.form-group input::placeholder { color: #475569; }
.form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
width: 100%; padding: 0.85rem; 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;
margin-top: 0.5rem;
}
.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; display: none;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
.step-indicator {
display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem;
}
.step {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,0.15); transition: background 0.3s;
}
.step.active { background: #7c3aed; }
.step.done { background: #22c55e; }
.features {
margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8;
}
.features li { margin: 0.4rem 0; list-style: none; }
.features li::before { content: '✦ '; color: #00d4ff; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Claim your rSpace</h1>
<p id="subtitle" class="sub">Create your passkey-protected identity</p>
<div class="step-indicator">
<div id="step1" class="step active"></div>
<div id="step2" class="step"></div>
<div id="step3" class="step"></div>
</div>
<div id="messageBox" class="message-box"></div>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<div id="registerForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" placeholder="your@email.com" readonly />
</div>
<button id="registerBtn" class="btn-primary" onclick="register()" disabled>
Create Passkey &amp; Claim
</button>
</div>
<p id="status" class="status"></p>
<ul class="features">
<li>No passwords — your device is the key</li>
<li>Works across the entire r* ecosystem</li>
<li>Privacy-first, encrypted by default</li>
</ul>
</div>
<script>
const TOKEN = ${JSON.stringify(token)};
let inviteData = null;
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
const statusEl = document.getElementById('status');
const registerBtn = document.getElementById('registerBtn');
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const messageBox = document.getElementById('messageBox');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
successEl.style.display = 'none';
statusEl.style.display = 'none';
}
function showStatus(msg) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
function setStep(n) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById('step' + i);
el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
}
}
// Load invite info
(async () => {
if (!TOKEN) {
showError('No invite token provided. You need an invitation link to join.');
return;
}
try {
const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
if (!res.ok) {
const data = await res.json();
showError(data.error || 'This invite is no longer valid.');
return;
}
inviteData = await res.json();
document.getElementById('subtitle').innerHTML =
'<span class="inviter">' + inviteData.invitedBy + '</span> invited you to join rSpace';
emailInput.value = inviteData.email;
if (inviteData.message) {
messageBox.textContent = '"' + inviteData.message + '"';
messageBox.style.display = 'block';
}
registerBtn.disabled = false;
} catch (err) {
showError('Failed to load invite. Please try again.');
}
})();
async function register() {
const username = usernameInput.value.trim();
if (!username || username.length < 2) {
showError('Username must be at least 2 characters');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showError('Username can only contain letters, numbers, hyphens, and underscores');
return;
}
registerBtn.disabled = true;
errorEl.style.display = 'none';
setStep(2);
showStatus('Starting passkey registration...');
try {
// Step 1: Start registration
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const startData = await startRes.json();
if (startData.error) {
showError(startData.error);
registerBtn.disabled = false;
setStep(1);
return;
}
// Step 2: Create credential
showStatus('Follow your browser prompt to create a passkey...');
const { options } = startData;
const challengeBytes = Uint8Array.from(
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const userIdBytes = Uint8Array.from(
atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const publicKeyOptions = {
challenge: challengeBytes,
rp: options.rp,
user: { ...options.user, id: userIdBytes },
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
};
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
// Step 3: Complete registration
setStep(3);
showStatus('Completing registration...');
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
challenge: options.challenge,
credential: {
credentialId,
attestationObject,
clientDataJSON,
transports: credential.response.getTransports ? credential.response.getTransports() : [],
},
}),
});
const completeData = await completeRes.json();
if (!completeData.success) {
showError(completeData.error || 'Registration failed');
registerBtn.disabled = false;
setStep(1);
return;
}
// Step 4: Claim the invite
showStatus('Claiming your invite...');
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken: completeData.token }),
});
const claimData = await claimRes.json();
if (claimData.error) {
// Registration succeeded but claim failed — still show partial success
showError('Account created but invite claim failed: ' + claimData.error);
return;
}
// Success!
document.getElementById('registerForm').style.display = 'none';
statusEl.style.display = 'none';
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace →</a>';
successEl.style.display = 'block';
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey creation was cancelled. Please try again.');
} else {
showError(err.message || 'Registration failed');
}
registerBtn.disabled = false;
setStep(1);
}
}
</script>
</body>
</html>`;
}
// ============================================================================
// OIDC PROVIDER (Authorization Code Flow)
// ============================================================================
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.ridentity.online';
// Discovery document
app.get('/.well-known/openid-configuration', (c) => {
return c.json({
issuer: OIDC_ISSUER,
authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`,
token_endpoint: `${OIDC_ISSUER}/oidc/token`,
userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
scopes_supported: ['openid', 'profile', 'email'],
claims_supported: ['sub', 'email', 'name', 'preferred_username'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
});
});
// Authorization endpoint
app.get('/oidc/authorize', async (c) => {
const clientId = c.req.query('client_id');
const redirectUri = c.req.query('redirect_uri');
const responseType = c.req.query('response_type');
const scope = c.req.query('scope') || 'openid profile email';
const state = c.req.query('state') || '';
if (responseType !== 'code') {
return c.text('Unsupported response_type', 400);
}
if (!clientId || !redirectUri) {
return c.text('Missing client_id or redirect_uri', 400);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.text('Unknown client_id', 400);
}
if (!client.redirectUris.includes(redirectUri)) {
return c.text('Invalid redirect_uri', 400);
}
// Serve the authorize page — it handles login + consent in one flow
return c.html(oidcAuthorizePage(client.name, clientId, redirectUri, scope, state));
});
// Authorization callback (POST from the authorize page after passkey login)
app.post('/oidc/authorize', async (c) => {
const body = await c.req.json();
const { clientId, redirectUri, scope, state, token } = body;
// Verify the session token from passkey login
let payload: any;
try {
payload = await verify(token, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid session' }, 401);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.json({ error: 'Unknown client' }, 400);
}
if (!client.redirectUris.includes(redirectUri)) {
return c.json({ error: 'Invalid redirect_uri' }, 400);
}
// Check email allowlist
const user = await getUserById(payload.sub);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const userEmail = user.email || user.profile_email;
if (client.allowedEmails.length > 0) {
if (!userEmail || !client.allowedEmails.includes(userEmail)) {
return c.json({ error: 'access_denied', message: 'You do not have access to this application.' }, 403);
}
}
// Generate auth code
const code = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
await createOidcAuthCode(code, clientId, payload.sub, redirectUri, scope || 'openid profile email');
const url = new URL(redirectUri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return c.json({ redirectUrl: url.toString() });
});
// Token endpoint
app.post('/oidc/token', async (c) => {
const contentType = c.req.header('content-type') || '';
let grantType: string, code: string, redirectUri: string, clientId: string, clientSecret: string;
if (contentType.includes('application/json')) {
const body = await c.req.json();
grantType = body.grant_type;
code = body.code;
redirectUri = body.redirect_uri;
clientId = body.client_id;
clientSecret = body.client_secret;
} else {
// application/x-www-form-urlencoded (standard OIDC)
const body = await c.req.parseBody();
grantType = body.grant_type as string;
code = body.code as string;
redirectUri = body.redirect_uri as string;
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
}
// Support Basic auth for client credentials
if (!clientId || !clientSecret) {
const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Basic ')) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const [id, secret] = decoded.split(':');
clientId = clientId || id;
clientSecret = clientSecret || secret;
}
}
if (grantType !== 'authorization_code') {
return c.json({ error: 'unsupported_grant_type' }, 400);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.json({ error: 'invalid_client' }, 401);
}
// Constant-time comparison for client secret
const expected = new TextEncoder().encode(client.clientSecret);
const received = new TextEncoder().encode(clientSecret || '');
if (expected.length !== received.length) {
return c.json({ error: 'invalid_client' }, 401);
}
let mismatch = 0;
for (let i = 0; i < expected.length; i++) {
mismatch |= expected[i] ^ received[i];
}
if (mismatch !== 0) {
return c.json({ error: 'invalid_client' }, 401);
}
// Consume auth code (atomic — marks used)
const authCode = await consumeOidcAuthCode(code);
if (!authCode) {
return c.json({ error: 'invalid_grant' }, 400);
}
if (authCode.clientId !== clientId) {
return c.json({ error: 'invalid_grant' }, 400);
}
if (authCode.redirectUri !== redirectUri) {
return c.json({ error: 'invalid_grant' }, 400);
}
// Look up user for claims
const user = await getUserById(authCode.userId);
if (!user) {
return c.json({ error: 'invalid_grant' }, 400);
}
const now = Math.floor(Date.now() / 1000);
const idTokenPayload = {
iss: OIDC_ISSUER,
sub: authCode.userId,
aud: clientId,
iat: now,
exp: now + 3600,
email: user.email || user.profile_email || '',
name: user.display_name || user.username,
preferred_username: user.username,
};
const idToken = await sign(idTokenPayload, CONFIG.jwtSecret);
// Access token is also a JWT with same claims (used by userinfo)
const accessTokenPayload = {
iss: OIDC_ISSUER,
sub: authCode.userId,
aud: clientId,
iat: now,
exp: now + 3600,
scope: authCode.scope,
};
const accessToken = await sign(accessTokenPayload, CONFIG.jwtSecret);
return c.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
id_token: idToken,
});
});
// UserInfo endpoint
app.get('/oidc/userinfo', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'invalid_token' }, 401);
}
const token = authHeader.slice(7);
let payload: any;
try {
payload = await verify(token, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'invalid_token' }, 401);
}
const user = await getUserById(payload.sub);
if (!user) {
return c.json({ error: 'invalid_token' }, 401);
}
return c.json({
sub: payload.sub,
email: user.email || user.profile_email || '',
name: user.display_name || user.username,
preferred_username: user.username,
});
});
// Authorize page HTML — embeds passkey login, then posts back to complete authorization
function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — EncryptID</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.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 1rem;
padding: 2.5rem;
max-width: 400px;
width: 90%;
text-align: center;
}
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.3rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
.app-name { color: #7c3aed; font-weight: 600; }
.btn-primary {
width: 100%; padding: 0.85rem; 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;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
.scope-list {
text-align: left; margin: 1rem 0 1.5rem; padding: 0.75rem 1rem;
background: rgba(255,255,255,0.04); border-radius: 0.5rem;
font-size: 0.85rem; color: #cbd5e1;
}
.scope-list li { margin: 0.3rem 0; list-style: none; }
.scope-list li::before { content: '✓ '; color: #22c55e; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Sign in with EncryptID</h1>
<p class="sub"><span class="app-name">${escapeHtml(appName)}</span> wants to access your account</p>
<ul class="scope-list">
<li>Your name and username</li>
<li>Your email address</li>
</ul>
<div id="error" class="error"></div>
<button id="loginBtn" class="btn-primary" onclick="startLogin()">
Sign in with Passkey
</button>
<p id="status" class="status"></p>
</div>
<script>
const CLIENT_ID = ${JSON.stringify(clientId)};
const REDIRECT_URI = ${JSON.stringify(redirectUri)};
const SCOPE = ${JSON.stringify(scope)};
const STATE = ${JSON.stringify(state)};
const errorEl = document.getElementById('error');
const statusEl = document.getElementById('status');
const loginBtn = document.getElementById('loginBtn');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
statusEl.style.display = 'none';
}
function showStatus(msg) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
async function startLogin() {
loginBtn.disabled = true;
errorEl.style.display = 'none';
showStatus('Starting passkey authentication...');
try {
// Step 1: Get authentication challenge
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
// Step 2: WebAuthn get assertion
const challengeBytes = Uint8Array.from(
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const publicKeyOptions = {
challenge: challengeBytes,
rpId: options.rpId,
userVerification: options.userVerification,
timeout: options.timeout,
};
if (options.allowCredentials) {
publicKeyOptions.allowCredentials = options.allowCredentials.map(c => ({
type: c.type,
id: Uint8Array.from(atob(c.id.replace(/-/g, '+').replace(/_/g, '/')), ch => ch.charCodeAt(0)),
transports: c.transports,
}));
}
showStatus('Touch your passkey...');
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
// Step 3: Complete authentication
const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId,
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
},
}),
});
const authResult = await completeRes.json();
if (!authResult.success) {
showError(authResult.error || 'Authentication failed');
loginBtn.disabled = false;
return;
}
// Step 4: Exchange session token for OIDC auth code
showStatus('Authorizing...');
const authorizeRes = await fetch('/oidc/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: SCOPE,
state: STATE,
token: authResult.token,
}),
});
const authorizeResult = await authorizeRes.json();
if (authorizeResult.error) {
showError(authorizeResult.message || authorizeResult.error);
loginBtn.disabled = false;
return;
}
// Step 5: Redirect back to the app with the auth code
showStatus('Redirecting...');
window.location.href = authorizeResult.redirectUrl;
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey authentication was cancelled.');
} else {
showError(err.message || 'Authentication failed');
}
loginBtn.disabled = false;
}
}
</script>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================================
// 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, .vault-section, .wallet-section, .spaces-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, .vault-section h4, .wallet-section h4, .spaces-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.wallet-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; }
.wallet-card .wallet-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; margin-bottom: 0.25rem; }
.wallet-card .wallet-addr { font-family: monospace; font-size: 0.85rem; color: #e2e8f0; word-break: break-all; }
.wallet-card .wallet-addr a { color: #60a5fa; text-decoration: none; }
.wallet-card .wallet-addr a:hover { text-decoration: underline; }
.wallet-card .wallet-balance { font-size: 0.8rem; color: #4ade80; margin-top: 0.25rem; }
.space-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
.space-card .space-name { font-weight: 600; color: #e2e8f0; }
.space-card .space-name a { color: #e2e8f0; text-decoration: none; }
.space-card .space-name a:hover { color: #60a5fa; }
.space-card .space-role { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
.space-wallet { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; font-family: monospace; }
.space-wallet a { color: #60a5fa; text-decoration: none; }
.space-wallet a:hover { text-decoration: underline; }
.recovery-row { display: flex; gap: 0.5rem; }
.recovery-row input { flex: 1; }
.recovery-row button { white-space: nowrap; }
/* Guardian items */
.guardian-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.85rem; }
.guardian-name { font-weight: 500; }
.guardian-status { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 1rem; }
.guardian-status.pending { background: rgba(234,179,8,0.15); color: #eab308; }
.guardian-status.accepted { background: rgba(34,197,94,0.15); color: #22c55e; }
.guardian-add { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.guardian-add input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #fff; font-size: 0.85rem; outline: none; }
.guardian-add input:focus { border-color: #7c3aed; }
/* Setup checklist */
.setup-checklist { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
.setup-checklist h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.check-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; font-size: 0.85rem; }
.check-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; flex-shrink: 0; }
.check-icon.done { background: rgba(34,197,94,0.2); color: #22c55e; }
.check-icon.todo { background: rgba(255,255,255,0.08); color: #64748b; }
/* Invite link display */
.invite-link { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; padding: 0.6rem; font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; margin-top: 0.5rem; cursor: pointer; position: relative; }
.invite-link:hover { border-color: #7c3aed; }
.invite-link .copy-hint { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-family: sans-serif; font-size: 0.7rem; color: #7c3aed; }
/* QR placeholder */
.qr-container { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
.qr-container canvas { border-radius: 8px; background: #fff; padding: 8px; }
.or-divider { color: #64748b; font-size: 0.8rem; margin: 0.5rem 0; }
/* Features */
.features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 2rem; }
.feature { background: rgba(255,255,255,0.04); padding: 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.08); text-align: center; }
.feature-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
.feature-desc { font-size: 0.8rem; color: #94a3b8; }
/* Apps bar */
.apps { text-align: center; padding-top: 2rem; border-top: 1px solid rgba(255,255,255,0.08); }
.apps-title { font-size: 0.8rem; color: #64748b; margin-bottom: 0.75rem; }
.app-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; }
.app-links a { color: #64748b; text-decoration: none; font-size: 0.85rem; transition: color 0.2s; }
.app-links a:hover { color: #00d4ff; }
.link-row { text-align: center; margin-top: 1rem; font-size: 0.8rem; }
.link-row a { color: #7c3aed; text-decoration: none; }
.link-row a:hover { text-decoration: underline; }
@media (max-width: 480px) {
.features { grid-template-columns: 1fr; }
.header h1 { font-size: 2rem; }
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1>rStack Identity</h1>
<p class="tagline">One passkey for the <a href="https://rstack.online" style="color:#00d4ff;text-decoration:none">rStack.online</a> ecosystem</p>
</div>
<!-- Auth card: login/register when signed out, profile when signed in -->
<div class="auth-card">
<!-- Login/Register form -->
<div id="auth-form">
<h2>Get started</h2>
<p class="sub">Sign in with your passkey or create a new account. No passwords, no tracking, no third parties.</p>
<div class="tabs">
<button class="tab active" id="tab-signin" onclick="switchTab('signin')">Sign In</button>
<button class="tab" id="tab-register" onclick="switchTab('register')">Register</button>
</div>
<div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div>
<!-- Register fields (hidden in signin mode) -->
<div id="register-fields" style="display:none">
<div class="form-group">
<label for="username">Username</label>
<input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
</div>
<div class="form-group">
<label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional — for account recovery)</span></label>
<input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
</div>
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
<div class="link-row" id="recovery-link-row">
<a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
</div>
<!-- Recovery form (hidden by default) -->
<div id="recovery-form" style="display:none;margin-top:1rem;">
<div class="form-group">
<label for="recover-email">Email or Username</label>
<input id="recover-email" type="text" placeholder="Enter your email or username" />
</div>
<button class="btn-primary" style="background:linear-gradient(90deg,#22c55e,#16a34a);" onclick="initiateRecovery()">Send Recovery Request</button>
<div id="recover-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
</div>
<!-- Profile (shown when logged in) -->
<div id="profile" class="profile">
<div class="profile-header">
<div class="profile-avatar" id="profile-avatar">?</div>
<div class="profile-info">
<h3 id="profile-username">Loading...</h3>
<div class="did" id="profile-did"></div>
</div>
</div>
<div class="profile-row"><span class="label">Session</span><span id="profile-session">Active</span></div>
<div class="profile-row"><span class="label">Token expires</span><span id="profile-expires">--</span></div>
<div class="passkeys">
<h4>Your Passkeys</h4>
<div id="passkey-list"><div class="passkey-item"><span class="passkey-id">Loading...</span></div></div>
</div>
<div class="setup-checklist" id="setup-checklist">
<h4>Account Security</h4>
<div class="check-item"><div class="check-icon done">&#10003;</div> Passkey created</div>
<div class="check-item"><div class="check-icon" id="check-email">&#9675;</div> <span id="check-email-text">Recovery email</span></div>
<div class="check-item"><div class="check-icon" id="check-device">&#9675;</div> <span id="check-device-text">Second device</span></div>
<div class="check-item"><div class="check-icon" id="check-guardians">&#9675;</div> <span id="check-guardians-text">Guardians (0/3)</span></div>
<div class="check-item"><div class="check-icon" id="check-vault">&#9675;</div> <span id="check-vault-text">Encrypted vault backup</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="vault-section">
<h4>Encrypted Account Vault</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your account data is encrypted with your passkey and stored securely. Only you can unlock it.</p>
<div id="vault-status" style="font-size:0.8rem;padding:0.5rem 0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">Status: Not synced</div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem">
<button class="btn-secondary" onclick="saveVault()">Save to Encrypted Server</button>
<button class="btn-secondary" onclick="restoreVault()">Restore from Server</button>
</div>
<div id="vault-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
<div class="wallet-section">
<h4>My Wallet</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your EncryptID-linked wallet for receiving and managing funds.</p>
<div id="wallet-info" style="font-size:0.85rem;padding:0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">No wallet linked</div>
<div id="wallet-claims" style="margin-top:0.75rem"></div>
</div>
<div class="spaces-section">
<h4>My Spaces</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Communities you belong to and their treasury wallets.</p>
<div id="spaces-list" style="font-size:0.85rem;color:#94a3b8">Loading...</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://rflows.online">rFlows</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 {
bufferToBase64url,
base64urlToBuffer,
getKeyManager,
getSessionManager,
detectCapabilities,
authenticatePasskey,
} 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...';
// Server-initiated registration flow
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
const regBody = {
challenge: serverOptions.challenge,
credential: credentialData,
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...';
// Server-initiated auth flow
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
},
});
if (!credential) throw new Error('Authentication failed');
const res = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
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) {
// If there's a redirect param, navigate there after login
const redirectUrl = new URLSearchParams(location.search).get('redirect');
if (redirectUrl && redirectUrl.startsWith('/')) {
location.href = redirectUrl;
return;
}
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, vault status, wallet, and spaces
loadGuardians();
checkVaultStatus();
loadWalletInfo(token, username);
loadUserSpaces(token);
}
async function loadWalletInfo(token, username) {
var walletEl = document.getElementById('wallet-info');
var claimsEl = document.getElementById('wallet-claims');
try {
var res = await fetch('/api/user/profile', {
headers: { 'Authorization': 'Bearer ' + token },
});
var data = await res.json();
if (data.profile && data.profile.walletAddress) {
var addr = data.profile.walletAddress;
var short = addr.slice(0, 6) + '...' + addr.slice(-4);
var rwalletUrl = 'https://' + (username || 'demo') + '.rspace.online/rwallet?address=' + encodeURIComponent(addr);
walletEl.innerHTML =
'<div class="wallet-card">' +
'<div class="wallet-label">Linked Wallet</div>' +
'<div class="wallet-addr"><a href="' + rwalletUrl + '" target="_blank">' + short + '</a></div>' +
'</div>';
} else {
walletEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">No wallet linked yet. Complete a fund claim or add one manually.</div>';
}
// Check for pending fund claims
var claimRes = await fetch('/api/user/claims', {
headers: { 'Authorization': 'Bearer ' + token },
});
if (claimRes.ok) {
var claimData = await claimRes.json();
if (claimData.claims && claimData.claims.length > 0) {
claimsEl.innerHTML = claimData.claims.map(function(cl) {
return '<div class="wallet-card" style="border-left:3px solid #f59e0b">' +
'<div class="wallet-label">Pending Claim</div>' +
'<div class="wallet-balance">$' + (cl.fiatAmount || '0') + ' ' + (cl.fiatCurrency || 'USD') + '</div>' +
'<div style="font-size:0.75rem;color:#64748b;margin-top:0.25rem">Expires ' + new Date(cl.expiresAt).toLocaleDateString() + '</div>' +
'<a href="/claim?token=' + cl.token + '" class="btn-secondary" style="margin-top:0.5rem;display:inline-block;font-size:0.8rem;padding:0.35rem 0.75rem">Claim Now</a>' +
'</div>';
}).join('');
}
}
} catch (err) {
console.error('Failed to load wallet info:', err);
}
}
async function loadUserSpaces(token) {
var spacesEl = document.getElementById('spaces-list');
try {
var res = await fetch('/api/user/spaces', {
headers: { 'Authorization': 'Bearer ' + token },
});
var data = await res.json();
if (!data.spaces || data.spaces.length === 0) {
spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">You are not a member of any spaces yet.</div>';
return;
}
spacesEl.innerHTML = data.spaces.map(function(s) {
var spaceUrl = 'https://' + s.spaceSlug + '.rspace.online';
var walletUrl = spaceUrl + '/rwallet';
return '<div class="space-card">' +
'<div>' +
'<div class="space-name"><a href="' + spaceUrl + '" target="_blank">' + s.spaceSlug + '</a></div>' +
'<div class="space-wallet"><a href="' + walletUrl + '" target="_blank">View Treasury</a></div>' +
'</div>' +
'<div class="space-role">' + s.role + '</div>' +
'</div>';
}).join('');
} catch (err) {
spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">Could not load spaces.</div>';
console.error('Failed to load spaces:', err);
}
}
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 data-guardian-id="' + g.id + '" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:0.8rem;" title="Remove">&times;</button>' +
'</span></div>'
).join('');
// Attach click handlers for remove buttons via data attributes
list.querySelectorAll('[data-guardian-id]').forEach(btn => {
btn.addEventListener('click', () => removeGuardian(btn.dataset.guardianId));
});
// 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);
}
}
// ── Vault helpers ──
const VAULT_SPACE = '__vault';
const VAULT_DOC = 'account-vault';
const enc = new TextEncoder();
async function deriveVaultKey(prfOutput) {
// Import PRF output as HKDF material
const master = await crypto.subtle.importKey('raw', prfOutput, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']);
// Derive space key: info = "rspace:__vault"
const spaceBits = await crypto.subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-space-key-v1'), info: enc.encode('rspace:' + VAULT_SPACE) },
master, 256
);
const spaceKey = await crypto.subtle.importKey('raw', spaceBits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']);
// Derive doc key: info = "doc:account-vault"
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-doc-key-v1'), info: enc.encode('doc:' + VAULT_DOC) },
spaceKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
}
function vaultMsg(text, ok) {
const el = document.getElementById('vault-msg');
el.textContent = text;
el.style.color = ok ? '#86efac' : '#fca5a5';
el.style.display = 'block';
}
function gatherVaultData() {
const username = document.getElementById('profile-username')?.textContent || null;
const email = document.getElementById('recovery-email')?.value?.trim() || null;
return {
version: 1,
profile: { displayName: username, avatarUrl: null },
emails: email ? [{ address: email, verified: false, addedAt: Date.now() }] : [],
devices: [],
addresses: [],
wallets: [],
preferences: {},
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
window.saveVault = async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) { vaultMsg('Not authenticated', false); return; }
try {
vaultMsg('Authenticate with your passkey to encrypt...', true);
const result = await authenticatePasskey();
if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; }
const key = await deriveVaultKey(result.prfOutput);
const vault = gatherVaultData();
const json = new TextEncoder().encode(JSON.stringify(vault));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, json));
// Pack: [12-byte nonce][ciphertext]
const packed = new Uint8Array(12 + ct.length);
packed.set(nonce, 0);
packed.set(ct, 12);
const res = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/octet-stream' },
body: packed,
});
if (!res.ok) throw new Error('Server returned ' + res.status);
vaultMsg('Vault saved and encrypted successfully.', true);
updateVaultChecklist(true);
document.getElementById('vault-status').textContent = 'Status: Synced — ' + new Date().toLocaleString();
} catch (err) {
if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; }
vaultMsg('Save failed: ' + err.message, false);
}
};
window.restoreVault = async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) { vaultMsg('Not authenticated', false); return; }
try {
vaultMsg('Authenticate with your passkey to decrypt...', true);
const result = await authenticatePasskey();
if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; }
const blobRes = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
headers: { 'Authorization': 'Bearer ' + token },
});
if (blobRes.status === 404) { vaultMsg('No vault backup found on server.', false); return; }
if (!blobRes.ok) throw new Error('Server returned ' + blobRes.status);
const packed = new Uint8Array(await blobRes.arrayBuffer());
if (packed.length < 13) throw new Error('Invalid vault data');
const nonce = packed.slice(0, 12);
const ct = packed.slice(12);
const key = await deriveVaultKey(result.prfOutput);
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
const vault = JSON.parse(new TextDecoder().decode(plainBuf));
// Populate DOM
if (vault.profile?.displayName) {
const el = document.getElementById('profile-username');
if (el) el.textContent = vault.profile.displayName;
}
if (vault.emails?.length > 0) {
const el = document.getElementById('recovery-email');
if (el) el.value = vault.emails[0].address;
}
vaultMsg('Vault restored successfully. ' + (vault.emails?.length || 0) + ' email(s), ' + (vault.wallets?.length || 0) + ' wallet(s).', true);
updateVaultChecklist(true);
document.getElementById('vault-status').textContent = 'Status: Restored — ' + new Date(vault.updatedAt).toLocaleString();
} catch (err) {
if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; }
vaultMsg('Restore failed: ' + err.message, false);
}
};
async function checkVaultStatus() {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
try {
const res = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
updateVaultChecklist(true);
document.getElementById('vault-status').textContent = 'Status: Backup exists on server';
} else {
updateVaultChecklist(false);
}
} catch { /* offline or error */ }
}
function updateVaultChecklist(exists) {
const icon = document.getElementById('check-vault');
const text = document.getElementById('check-vault-text');
if (exists) {
icon.className = 'check-icon done';
icon.innerHTML = '&#10003;';
text.textContent = 'Encrypted vault backup';
} else {
icon.className = 'check-icon todo';
icon.innerHTML = '&#9675;';
text.textContent = 'Encrypted vault backup';
}
}
// ── Checklist update ──
function updateChecklist(guardians) {
const token = localStorage.getItem(TOKEN_KEY);
// Email check
const emailInput = document.getElementById('recovery-email');
const hasEmail = emailInput && emailInput.value.trim();
const emailIcon = document.getElementById('check-email');
if (hasEmail) {
emailIcon.className = 'check-icon done';
emailIcon.innerHTML = '&#10003;';
}
// Device check (from passkey count)
const passkeyItems = document.querySelectorAll('.passkey-item');
const deviceIcon = document.getElementById('check-device');
const deviceText = document.getElementById('check-device-text');
if (passkeyItems.length > 1) {
deviceIcon.className = 'check-icon done';
deviceIcon.innerHTML = '&#10003;';
deviceText.textContent = passkeyItems.length + ' devices linked';
}
// Guardian check
const guardianIcon = document.getElementById('check-guardians');
const guardianText = document.getElementById('check-guardians-text');
const gList = guardians || [];
const accepted = gList.filter(g => g.status === 'accepted').length;
const total = gList.length;
guardianText.textContent = 'Guardians (' + total + '/3' + (accepted < total ? ', ' + accepted + ' accepted' : '') + ')';
if (total >= 3 && accepted >= 2) {
guardianIcon.className = 'check-icon done';
guardianIcon.innerHTML = '&#10003;';
} else if (total > 0) {
guardianIcon.className = 'check-icon todo';
guardianIcon.innerHTML = total.toString();
}
}
// On page load: handle query params and check for existing token
(async () => {
const params = new URLSearchParams(location.search);
if (params.get('tab') === 'register') switchTab('register');
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 with retries (Docker networking may take a moment)
(async () => {
const maxRetries = 5;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await initDatabase();
break;
} catch (err) {
console.error(`EncryptID: Database init attempt ${attempt}/${maxRetries} failed:`, (err as Error).message);
if (attempt === maxRetries) {
console.error('EncryptID: All database init attempts exhausted, exiting');
process.exit(1);
}
await new Promise(r => setTimeout(r, attempt * 2000));
}
}
// Seed OIDC clients from environment (after DB is ready)
try {
const clientsJson = process.env.OIDC_CLIENTS;
if (clientsJson) {
const clients = JSON.parse(clientsJson);
await seedOidcClients(clients);
console.log(`EncryptID: Seeded ${clients.length} OIDC client(s)`);
}
} catch (err) {
console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message);
}
})();
// Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes
setInterval(() => {
cleanExpiredChallenges().catch(() => {});
cleanExpiredRecoveryTokens().catch(() => {});
cleanExpiredFundClaims().catch(() => {});
cleanExpiredOidcCodes().catch(() => {});
cleanExpiredIdentityInvites().catch(() => {});
}, 10 * 60 * 1000);
console.log(`
╔═══════════════════════════════════════════════════════════╗
║ ║
║ 🔐 rStack Identity (EncryptID) ║
║ ║
║ One passkey for the rStack.online ecosystem ║
║ ║
║ Port: ${CONFIG.port}
║ RP ID: ${CONFIG.rpId}
║ Storage: PostgreSQL ║
║ SMTP: ${CONFIG.smtp.pass ? CONFIG.smtp.host : 'disabled (no SMTP_PASS)'}${' '.repeat(Math.max(0, 36 - (CONFIG.smtp.pass ? CONFIG.smtp.host.length : 26)))}
║ ║
╚═══════════════════════════════════════════════════════════╝
`);
export default {
port: CONFIG.port,
fetch: app.fetch,
};