import { cloudApi } from "./cloud-state.js"; const TABLES = { memories: "lifeos_memories", goals: "lifeos_goals", tasks: "lifeos_tasks", museum: "lifeos_favorites", profile: "lifeos_meta", settings: "lifeos_meta", system: "lifeos_meta" }; const META = new Set(["profile", "settings", "system"]); const DURATION = 20 * 60 * 1000; // The deployed Workers runtime rejects PBKDF2 counts above 100,000. const ITERATIONS = 100_000; const body = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } }); const validId = value => typeof value === "string" && value.length > 0 && value.length <= 200; const hash = async value => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))].map(byte => byte.toString(16).padStart(2, "0")).join(""); const secureEqual = (first, second) => { if (typeof first !== "string" || typeof second !== "string" || first.length !== second.length) return false; let difference = 0; for (let i = 0; i < first.length; i++) difference |= first.charCodeAt(i) ^ second.charCodeAt(i); return difference === 0; }; const hex = bytes => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); const unhex = value => Uint8Array.from(value.match(/../g).map(pair => parseInt(pair, 16))); async function derive(password, salt, iterations = ITERATIONS) { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); return hex(new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: unhex(salt), iterations }, key, 256))); } const validPassword = value => typeof value === "string" && value.length >= 12 && value.length <= 256; const salt = () => hex(crypto.getRandomValues(new Uint8Array(16))); const token = () => { const bytes = crypto.getRandomValues(new Uint8Array(32)); return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }; async function signedIn(request, db, userId) { const raw = request.headers.get("x-lifeos-admin-session"); if (!raw || raw.length > 100) return false; const record = await db.prepare("SELECT s.expires_at FROM lifeos_admin_sessions s JOIN lifeos_admin_credentials c ON c.user_id = s.user_id WHERE s.token_hash = ? AND s.user_id = ?").bind(await hash(raw), userId).first(); return !!record && record.expires_at > Date.now(); } function originAllowed(request) { const origin = request.headers.get("origin"); return !origin || origin === new URL(request.url).origin; } async function readInput(request) { const text = await request.text(); if (text.length > 2048) return null; try { return JSON.parse(text); } catch { return null; } } async function newSession(db, userId) { const raw = token(), expiresAt = Date.now() + DURATION; await db.prepare("INSERT INTO lifeos_admin_sessions(token_hash, user_id, expires_at) VALUES (?, ?, ?)").bind(await hash(raw), userId, expiresAt).run(); return body({ token: raw, expiresAt }); } async function setup(request, db, userId) { const input = await readInput(request); if (input?.acknowledged !== true || input.username !== "lifeos-admin" || !validPassword(input.password)) return body({ error: "Invalid setup" }, 400); const newSalt = salt(), passwordHash = await derive(input.password, newSalt); const result = await db.prepare("INSERT INTO lifeos_admin_credentials(user_id, salt, password_hash, iterations, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING") .bind(userId, newSalt, passwordHash, ITERATIONS, new Date().toISOString()).run(); if (!result.meta.changes) return body({ error: "Password already configured" }, 409); await db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId).run(); await db.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run(); return newSession(db, userId); } async function login(request, env, userId) { const credential = await env.DB.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); if (!credential) return body({ error: "Set your password first" }, 409); const row = await env.DB.prepare("SELECT failures, locked_until FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).first(); if (row?.locked_until > Date.now()) return body({ error: "Too many attempts", retryAt: row.locked_until }, 429); const input = await readInput(request); if (input?.acknowledged !== true) return body({ error: "Direct database access must be acknowledged" }, 400); const matched = input.username === "lifeos-admin" && validPassword(input.password) && secureEqual(await derive(input.password, credential.salt, credential.iterations), credential.password_hash); if (!matched) { const failures = (row?.failures || 0) + 1, until = failures >= 5 ? Date.now() + 15 * 60_000 : 0; await env.DB.prepare("INSERT INTO lifeos_admin_attempts(user_id, failures, locked_until) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET failures = excluded.failures, locked_until = excluded.locked_until").bind(userId, failures >= 5 ? 0 : failures, until).run(); return body({ error: "Invalid credentials", retryAt: until || null }, 401); } await env.DB.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run(); return newSession(env.DB, userId); } async function changePassword(request, db, userId) { const input = await readInput(request); if (!validPassword(input?.currentPassword) || !validPassword(input?.newPassword)) return body({ error: "Invalid password" }, 400); const credential = await db.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); if (!credential || !secureEqual(await derive(input.currentPassword, credential.salt, credential.iterations), credential.password_hash)) return body({ error: "Current password is incorrect" }, 401); if (input.newPassword === input.currentPassword) return body({ error: "Choose a different password" }, 400); const newSalt = salt(), passwordHash = await derive(input.newPassword, newSalt); await db.batch([ db.prepare("UPDATE lifeos_admin_credentials SET salt = ?, password_hash = ?, iterations = ?, updated_at = ? WHERE user_id = ?") .bind(newSalt, passwordHash, ITERATIONS, new Date().toISOString(), userId), db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId) ]); return newSession(db, userId); } async function list(db, userId, kind, page) { const table = TABLES[kind], offset = page * 50; const filter = META.has(kind) ? "user_id = ? AND kind = ?" : "user_id = ? AND deleted_at IS NULL"; const bindings = META.has(kind) ? [userId, kind] : [userId]; const rows = await db.prepare(`SELECT ${META.has(kind) ? "kind" : "id"} AS id, value_json, revision, updated_at FROM ${table} WHERE ${filter} ORDER BY updated_at DESC LIMIT 50 OFFSET ?`).bind(...bindings, offset).all(); const count = await db.prepare(`SELECT COUNT(*) AS total FROM ${table} WHERE ${filter}`).bind(...bindings).first(); return body({ rows: rows.results.map(row => ({ id: row.id, value: JSON.parse(row.value_json), revision: row.revision, updatedAt: row.updated_at })), total: count.total, page }); } async function mutate(request, env, userId, kind, id, method) { if (!validId(id) || (META.has(kind) && id !== kind)) return body({ error: "Invalid record ID" }, 400); let input = {}; if (method !== "DELETE") { const text = await request.text(); if (text.length > 1_100_000) return body({ error: "Record is too large" }, 413); try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); } } else { input.baseVersion = Number(new URL(request.url).searchParams.get("version")); } const baseVersion = input.baseVersion; if (!Number.isInteger(baseVersion) || baseVersion < 0 || (method !== "POST" && baseVersion === 0)) return body({ error: "Invalid revision" }, 400); if (method === "POST" && baseVersion !== 0) return body({ error: "New records start at revision zero" }, 400); if (method === "DELETE" && META.has(kind)) { const result = await env.DB.prepare("DELETE FROM lifeos_meta WHERE user_id = ? AND kind = ? AND revision = ?").bind(userId, kind, baseVersion).run(); return result.meta.changes ? body({ ok: true }) : body({ error: "Conflict" }, 409); } if (method !== "DELETE" && (!input.value || typeof input.value !== "object" || Array.isArray(input.value) || (!META.has(kind) && input.value.id !== id))) return body({ error: "Invalid record JSON" }, 400); return cloudApi(new Request(new URL("/api/lifeos-sync", request.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind, id, baseVersion, value: method === "DELETE" ? null : input.value }) }), env, userId, "/api/lifeos-sync"); } export async function dbAdminApi(request, env, userId, pathname) { if (!userId) return body({ error: "Authentication required" }, 401); if (!env.DB) return body({ error: "Database unavailable" }, 503); if (!originAllowed(request)) return body({ error: "Origin rejected" }, 403); const segments = pathname.slice("/api/db-admin".length).split("/").filter(Boolean); try { if (segments[0] === "config" && segments.length === 1 && request.method === "GET") { const credential = await env.DB.prepare("SELECT user_id FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first(); return body({ configured: !!credential }); } if (segments[0] === "setup" && segments.length === 1 && request.method === "POST") return setup(request, env.DB, userId); if (segments[0] === "login" && segments.length === 1 && request.method === "POST") return login(request, env, userId); if (!await signedIn(request, env.DB, userId)) return body({ error: "Admin session required" }, 401); if (segments[0] === "password" && segments.length === 1 && request.method === "POST") return changePassword(request, env.DB, userId); if (segments[0] === "session" && segments.length === 1 && request.method === "GET") return body({ ok: true }); if (segments[0] === "logout" && segments.length === 1 && request.method === "POST") { await env.DB.prepare("DELETE FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(request.headers.get("x-lifeos-admin-session")), userId).run(); return body({ ok: true }); } const kind = segments[1]; if (segments[0] !== "tables" || !Object.hasOwn(TABLES, kind)) return body({ error: "Unknown table" }, 404); if (segments.length === 3 && segments[2] === "rows" && request.method === "GET") { const page = Number(new URL(request.url).searchParams.get("page") || 0); if (!Number.isInteger(page) || page < 0 || page > 1000) return body({ error: "Invalid page" }, 400); return list(env.DB, userId, kind, page); } if (segments.length === 4 && segments[2] === "rows" && ["POST", "PATCH", "DELETE"].includes(request.method)) { return mutate(request, env, userId, kind, decodeURIComponent(segments[3]), request.method); } return body({ error: "Method not allowed" }, 405); } catch (error) { console.error("Life OS database manager failed", error); return body({ error: "Database operation failed" }, 500); } }