diff --git a/kidlisp/tools/logs.mjs b/kidlisp/tools/logs.mjs new file mode 100755 index 000000000..fe69ed550 --- /dev/null +++ b/kidlisp/tools/logs.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// KidLisp Log Viewer (admin-only) +// Pulls client-side telemetry from /api/kidlisp-log and /api/boot-log +// +// Auth: set AC_ADMIN_TOKEN env var or pass --token +// +// Usage: +// ./logs.mjs # recent kidlisp logs (default 20) +// ./logs.mjs --limit 50 # last 50 entries +// ./logs.mjs --type gpu-disabled # filter by log type +// ./logs.mjs --since 2026-03-20 # logs since date +// ./logs.mjs --boots # boot telemetry instead +// ./logs.mjs --boots --errors # only boot errors +// ./logs.mjs --stats # type breakdown counts +// ./logs.mjs --token # pass auth token inline + +const BASE = process.env.AC_API_URL || "https://aesthetic.computer"; + +const args = process.argv.slice(2); +function flag(name) { + return args.includes(`--${name}`); +} +function opt(name, fallback) { + const i = args.indexOf(`--${name}`); + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; +} + +const token = opt("token", null) || process.env.AC_ADMIN_TOKEN; +if (!token) { + console.error( + "Admin token required. Set AC_ADMIN_TOKEN or pass --token ", + ); + process.exit(1); +} + +const authHeaders = { Authorization: `Bearer ${token}` }; + +async function main() { + if (flag("boots")) { + await fetchBoots(); + } else { + await fetchKidlispLogs(); + } +} + +async function fetchKidlispLogs() { + const limit = opt("limit", "20"); + const type = opt("type", null); + const since = opt("since", null); + const ua = opt("ua", null); + + const params = new URLSearchParams({ limit }); + if (type) params.set("type", type); + if (since) params.set("since", since); + if (ua) params.set("ua", ua); + + const url = `${BASE}/api/kidlisp-log?${params}`; + const res = await fetch(url, { headers: authHeaders }); + if (!res.ok) { + console.error(`Error ${res.status}: ${await res.text()}`); + process.exit(1); + } + + const { logs, stats } = await res.json(); + + if (flag("stats")) { + console.log("\n Type breakdown:"); + for (const s of stats) { + console.log(` ${s._id}: ${s.count}`); + } + console.log(); + return; + } + + if (logs.length === 0) { + console.log("No logs found."); + return; + } + + for (const log of logs) { + const date = new Date(log.createdAt).toLocaleString(); + const device = log.device?.userAgent?.slice(0, 60) || "unknown"; + const country = log.server?.country || "??"; + const detail = log.detail + ? typeof log.detail === "object" + ? JSON.stringify(log.detail) + : log.detail + : ""; + console.log( + `[${date}] ${log.type || "?"} ${country} ${log.effect || ""} ${detail}`, + ); + console.log(` device: ${device}`); + if (log.gpuStatus) { + const failed = Object.entries(log.gpuStatus) + .filter(([, v]) => v > 0) + .map(([k, v]) => `${k}:${v}`); + if (failed.length) console.log(` gpu failures: ${failed.join(", ")}`); + } + } + console.log(`\n Showing ${logs.length} entries.`); +} + +async function fetchBoots() { + const limit = opt("limit", "30"); + const params = new URLSearchParams({ limit }); + + const url = `${BASE}/api/boot-log?${params}`; + const res = await fetch(url, { headers: authHeaders }); + if (!res.ok) { + console.error(`Error ${res.status}: ${await res.text()}`); + process.exit(1); + } + + const { boots } = await res.json(); + + const errorsOnly = flag("errors"); + const filtered = errorsOnly + ? boots.filter((b) => b.status === "error") + : boots; + + if (filtered.length === 0) { + console.log(errorsOnly ? "No boot errors found." : "No boot logs found."); + return; + } + + for (const boot of filtered) { + const date = new Date(boot.createdAt).toLocaleString(); + const status = boot.status || "?"; + const host = boot.meta?.host || "?"; + const path = boot.meta?.path || "/"; + const country = boot.server?.country || "??"; + + const icon = + status === "error" ? "X" : status === "success" ? "+" : "-"; + + console.log(`[${icon}] ${date} ${status} ${country} ${host}${path}`); + + if (boot.error) { + const errMsg = + typeof boot.error === "string" + ? boot.error + : boot.error.message || JSON.stringify(boot.error).slice(0, 120); + console.log(` error: ${errMsg}`); + } + + if (boot.events?.length) { + const errEvents = boot.events.filter( + (e) => e.level === "error" || e.label?.includes("error"), + ); + for (const ev of errEvents) { + console.log(` event: ${ev.label || ev.message || JSON.stringify(ev)}`); + } + } + } + console.log(`\n Showing ${filtered.length} of ${boots.length} entries.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/system/netlify/functions/boot-log.mjs b/system/netlify/functions/boot-log.mjs index 9a5bfbf05..a184fd417 100644 --- a/system/netlify/functions/boot-log.mjs +++ b/system/netlify/functions/boot-log.mjs @@ -2,6 +2,7 @@ // POST /api/boot-log import { connect } from "../../backend/database.mjs"; +import { authorize, hasAdmin } from "../../backend/authorization.mjs"; import { respond } from "../../backend/http.mjs"; export async function handler(event) { @@ -10,6 +11,11 @@ export async function handler(event) { } if (event.httpMethod === "GET") { + // Admin-only: require auth + const user = await authorize(event.headers); + if (!user) return respond(401, { error: "Authentication required" }); + if (!(await hasAdmin(user))) return respond(403, { error: "Admin access required" }); + try { const database = await connect(); const boots = database.db.collection("boots"); diff --git a/system/netlify/functions/kidlisp-log.mjs b/system/netlify/functions/kidlisp-log.mjs index 3105679e9..29cd4767b 100644 --- a/system/netlify/functions/kidlisp-log.mjs +++ b/system/netlify/functions/kidlisp-log.mjs @@ -3,6 +3,7 @@ // GET /api/kidlisp-log — query recent logs (with optional filters) import { connect } from "../../backend/database.mjs"; +import { authorize, hasAdmin } from "../../backend/authorization.mjs"; import { respond } from "../../backend/http.mjs"; const COLLECTION = "kidlisp-logs"; @@ -13,6 +14,11 @@ export async function handler(event) { } if (event.httpMethod === "GET") { + // Admin-only: require auth + const user = await authorize(event.headers); + if (!user) return respond(401, { error: "Authentication required" }); + if (!(await hasAdmin(user))) return respond(403, { error: "Admin access required" }); + try { const database = await connect(); const col = database.db.collection(COLLECTION); @@ -45,7 +51,11 @@ export async function handler(event) { } if (event.httpMethod === "DELETE") { - // Purge logs (for silo admin use) + // Admin-only: require auth + const delUser = await authorize(event.headers); + if (!delUser) return respond(401, { error: "Authentication required" }); + if (!(await hasAdmin(delUser))) return respond(403, { error: "Admin access required" }); + try { const database = await connect(); const col = database.db.collection(COLLECTION); diff --git a/system/public/aesthetic.computer/lib/kidlisp.mjs b/system/public/aesthetic.computer/lib/kidlisp.mjs index f48e7d3bd..7d64ee927 100644 --- a/system/public/aesthetic.computer/lib/kidlisp.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp.mjs @@ -7079,15 +7079,28 @@ class KidLisp { // For the first argument (image source), support unquoted URLs, paths, #codes, and @handles if (index === 0 && typeof arg === "string" && arg) { // If it's a URL (http/https), path, #code, or @handle, treat it as unquoted - if (arg.startsWith("http://") || arg.startsWith("https://") || + if (arg.startsWith("http://") || arg.startsWith("https://") || arg.includes("/") || arg.includes("@") || arg.startsWith("#")) { return arg; // Return as-is for URLs, paths, #codes, and @handle/timestamp patterns } } // Evaluate expressions for non-string arguments (like math expressions) - return this.evaluate(arg, api, this.localEnv); + const result = this.evaluate(arg, api, this.localEnv); + // Guard: if first arg evaluated to a function (e.g. builtin name + // like "circle"), treat the raw string as an image code instead. + if (index === 0 && typeof result === "function") { + return typeof arg === "string" ? arg : result; + } + return result; }); + // Bail out if the image source is invalid (not a string or bitmap) + if (processedArgs.length > 0 && typeof processedArgs[0] !== "string" && + (typeof processedArgs[0] !== "object" || !processedArgs[0]?.width)) { + console.warn("⚠️ stamp: invalid image source", processedArgs[0]); + return; + } + const performStamp = () => { api.stamp(...processedArgs); };