249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
/**
|
|
* auth.ts — Token auth, user config, agent mappings
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
// --- Session tokens ---
|
|
const SESSION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
const ENV_SUFFIX = process.env.PORT === '3001' ? 'prod' : 'dev';
|
|
const SESSION_TOKEN_FILE = path.join(import.meta.dir, `.session-tokens-${ENV_SUFFIX}.json`);
|
|
const sessionTokenMap = new Map<string, { user: string; expiresAt: number }>();
|
|
|
|
function persistSessionTokens() {
|
|
try {
|
|
const obj: Record<string, { user: string; expiresAt: number }> = {};
|
|
for (const [tok, entry] of sessionTokenMap) obj[tok] = entry;
|
|
fs.writeFileSync(SESSION_TOKEN_FILE, JSON.stringify(obj), 'utf8');
|
|
} catch (e: any) { console.error('Failed to persist session tokens:', e.message); }
|
|
}
|
|
|
|
function loadSessionTokens() {
|
|
try {
|
|
if (!fs.existsSync(SESSION_TOKEN_FILE)) return;
|
|
const obj = JSON.parse(fs.readFileSync(SESSION_TOKEN_FILE, 'utf8'));
|
|
const now = Date.now();
|
|
for (const [tok, entry] of Object.entries(obj) as [string, any][]) {
|
|
if (entry.expiresAt > now) sessionTokenMap.set(tok, entry);
|
|
}
|
|
console.log(`Loaded ${sessionTokenMap.size} session token(s) from disk.`);
|
|
} catch (e: any) { console.error('Failed to load session tokens:', e.message); }
|
|
}
|
|
|
|
loadSessionTokens();
|
|
|
|
export function issueSessionToken(user: string): string {
|
|
const token = randomUUID();
|
|
sessionTokenMap.set(token, { user, expiresAt: Date.now() + SESSION_TOKEN_TTL_MS });
|
|
persistSessionTokens();
|
|
return token;
|
|
}
|
|
|
|
export function getUserForSessionToken(token: string | null | undefined): string | null {
|
|
if (!token) return null;
|
|
const entry = sessionTokenMap.get(token);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt < Date.now()) { sessionTokenMap.delete(token); persistSessionTokens(); return null; }
|
|
return entry.user;
|
|
}
|
|
|
|
export function revokeSessionToken(token: string) {
|
|
sessionTokenMap.delete(token);
|
|
persistSessionTokens();
|
|
}
|
|
|
|
export function revokeAllSessionTokensForUser(user: string) {
|
|
for (const [tok, entry] of sessionTokenMap) {
|
|
if (entry.user === user) sessionTokenMap.delete(tok);
|
|
}
|
|
persistSessionTokens();
|
|
}
|
|
|
|
// --- Auth nonces (prevent login spam) ---
|
|
const NONCE_TTL_MS = 60 * 1000;
|
|
const nonceMap = new Map<string, number>(); // nonce → expiresAt
|
|
|
|
export function issueAuthNonce(): string {
|
|
const nonce = randomUUID();
|
|
nonceMap.set(nonce, Date.now() + NONCE_TTL_MS);
|
|
return nonce;
|
|
}
|
|
|
|
export function consumeAuthNonce(nonce: string): boolean {
|
|
const expiresAt = nonceMap.get(nonce);
|
|
if (!expiresAt) return false;
|
|
nonceMap.delete(nonce);
|
|
if (expiresAt < Date.now()) return false;
|
|
return true;
|
|
}
|
|
|
|
// --- OTP challenges ---
|
|
const OTP_TTL_MS = 5 * 60 * 1000;
|
|
const otpChallengeMap = new Map<string, { user: string; otp: string; expiresAt: number }>();
|
|
|
|
export function issueOtpChallenge(user: string): { challengeId: string; otp: string } {
|
|
const challengeId = randomUUID();
|
|
const otp = String(Math.floor(10000 + Math.random() * 90000));
|
|
otpChallengeMap.set(challengeId, { user, otp, expiresAt: Date.now() + OTP_TTL_MS });
|
|
return { challengeId, otp };
|
|
}
|
|
|
|
export function verifyOtpChallenge(challengeId: string, otp: string): string | null {
|
|
const entry = otpChallengeMap.get(challengeId);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt < Date.now()) { otpChallengeMap.delete(challengeId); return null; }
|
|
if (entry.otp !== otp) return null;
|
|
otpChallengeMap.delete(challengeId);
|
|
return entry.user;
|
|
}
|
|
|
|
// --- Static token map ---
|
|
export const TOKENS: Record<string, string> = {
|
|
'nico38638': 'nico',
|
|
'tina38638': 'tina',
|
|
'test123': 'test',
|
|
'niclas38638': 'niclas',
|
|
'loona38638': 'loona',
|
|
'hendrik38638': 'hendrik',
|
|
'eras38638': 'eras',
|
|
};
|
|
|
|
export const userDefaultAgent: Record<string, string> = {
|
|
'nico': 'titan',
|
|
'tina': 'adoree',
|
|
'niclas': 'alfred',
|
|
'loona': 'ash',
|
|
'hendrik': 'willi',
|
|
'eras': 'eras',
|
|
};
|
|
|
|
export const userViewerRoots: Record<string, string[]> = {
|
|
'nico': ['shared', 'workspace-titan'],
|
|
'tina': ['shared', 'workspace-adoree'],
|
|
'eras': ['shared', 'workspace-eras'],
|
|
'niclas': ['shared', 'workspace-alfred'],
|
|
'loona': ['shared', 'workspace-ash'],
|
|
'hendrik': ['shared', 'workspace-willi'],
|
|
};
|
|
|
|
export function getTokenForUser(user: string): string | null {
|
|
const token = user + '123';
|
|
return TOKENS[token] ? token : null;
|
|
}
|
|
|
|
export function getUserForToken(token: string): string | null {
|
|
return TOKENS[token] || null;
|
|
}
|
|
|
|
// --- OpenClaw config cache ---
|
|
let cachedOpenClawConfig: any = null;
|
|
let lastOpenClawRead = 0;
|
|
const OPENCLAW_CACHE_DURATION_MS = 2_000;
|
|
|
|
function getOpenClawConfig(): any {
|
|
if (cachedOpenClawConfig && (Date.now() - lastOpenClawRead < OPENCLAW_CACHE_DURATION_MS)) {
|
|
return cachedOpenClawConfig;
|
|
}
|
|
try {
|
|
cachedOpenClawConfig = JSON.parse(fs.readFileSync('/home/openclaw/.openclaw/openclaw.json', 'utf8'));
|
|
lastOpenClawRead = Date.now();
|
|
return cachedOpenClawConfig;
|
|
} catch (err: any) {
|
|
console.error('Error reading openclaw.json:', err.message);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// --- agents.json metadata ---
|
|
const AGENTS_META_PATH = path.join(import.meta.dir, 'agents.json');
|
|
|
|
interface AgentMeta { segment?: string; owner?: string; members?: string[]; modes?: string[]; }
|
|
|
|
function loadAgentsMeta(): Record<string, AgentMeta> {
|
|
try { return JSON.parse(fs.readFileSync(AGENTS_META_PATH, 'utf8')); }
|
|
catch { return {}; }
|
|
}
|
|
|
|
export function getAgentSegment(agentId: string): string {
|
|
const meta = loadAgentsMeta();
|
|
return (meta[agentId]?.segment) ?? 'utility';
|
|
}
|
|
|
|
function computeRole(meta: AgentMeta, user: string): string {
|
|
if (meta.segment === 'private') return 'private';
|
|
if (meta.segment === 'public') return 'public';
|
|
if (meta.segment === 'common') return 'common';
|
|
if (meta.segment === 'personal') {
|
|
if (meta.owner === user) return 'owner';
|
|
if (meta.members?.includes('*') || meta.members?.includes(user)) return 'member';
|
|
return 'guest';
|
|
}
|
|
return 'common';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session context prompts — injected per mode to set privacy boundaries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SESSION_PROMPTS = {
|
|
owner_private: 'IMPORTANT: This is a private 1:1 session with your owner. Be direct, informal, use their preferences. You may reference all prior context freely.',
|
|
user_private: 'IMPORTANT: This is a private 1:1 session with {user} (NOT your owner). Do NOT greet your owner. Do NOT share details from your owner\'s private sessions. Do NOT rewrite your handover. Address {user} directly.',
|
|
public: 'IMPORTANT: This is a PUBLIC shared channel. Multiple users may be reading. Do NOT greet any specific person by name. Do NOT reference private conversations. Do NOT rewrite your handover. Be professional and neutral. Address the room, not an individual.',
|
|
} as const;
|
|
|
|
/**
|
|
* Returns the session context prompt for a given session key and user.
|
|
* Selection logic:
|
|
* - public session (web:public or :main) → public prompt
|
|
* - private session, user is agent owner → owner_private prompt
|
|
* - private session, user is not owner → user_private prompt (with {user} replaced)
|
|
*/
|
|
export function getSessionPrompt(agentId: string, sessionKey: string, user: string): string {
|
|
const isPublic = sessionKey.includes(':web:public') || sessionKey.endsWith(':main');
|
|
if (isPublic) return SESSION_PROMPTS.public;
|
|
|
|
const meta = loadAgentsMeta();
|
|
const m = meta[agentId];
|
|
if (m?.owner === user) return SESSION_PROMPTS.owner_private;
|
|
return SESSION_PROMPTS.user_private.replace('{user}', user);
|
|
}
|
|
|
|
export function getAgentList(): Array<{ id: string; name: string }> {
|
|
const cfg = getOpenClawConfig();
|
|
return (cfg.agents?.list || []).map((a: any) => ({
|
|
id: a.id,
|
|
name: a.id.charAt(0).toUpperCase() + a.id.slice(1),
|
|
}));
|
|
}
|
|
|
|
export function getAgentListWithModels(user?: string): Array<{ id: string; name: string; model: string; modelFull: string; segment: string; role: string; modes: string[] }> {
|
|
const cfg = getOpenClawConfig();
|
|
const meta = loadAgentsMeta();
|
|
return (cfg.agents?.list || []).map((a: any) => {
|
|
const m = meta[a.id] ?? {};
|
|
return {
|
|
id: a.id,
|
|
name: a.id.charAt(0).toUpperCase() + a.id.slice(1),
|
|
model: (a.model || '').split('/').pop() || '',
|
|
modelFull: a.model || '',
|
|
segment: m.segment ?? 'utility',
|
|
role: user ? computeRole(m, user) : (m.segment ?? 'utility'),
|
|
modes: m.modes ?? ['private', 'public'],
|
|
};
|
|
});
|
|
}
|
|
|
|
function getUserAllowedAgents(user: string): string[] {
|
|
const all = getAgentList().map(a => a.id);
|
|
const filtered = all.filter(id => id !== 'tester' && id !== 'tested');
|
|
if (user === 'nico') return all;
|
|
if (user === 'eras') return ['eras'];
|
|
return filtered;
|
|
}
|
|
|
|
export const userAllowedAgents: Record<string, string[]> = new Proxy({} as Record<string, string[]>, {
|
|
get(_, user: string) { return getUserAllowedAgents(user); },
|
|
});
|