Monorepo for Aesthetic.Computer aesthetic.computer

fix: stamp crash on builtin names + admin-gate log endpoints + add logs CLI

Prevent crash when KidLisp evaluates (stamp circle) by guarding against
function values in the first arg. Gate GET/DELETE on kidlisp-log and
boot-log behind admin auth. Add kidlisp/tools/logs.mjs CLI for pulling
telemetry with a bearer token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+193 -3
+161
kidlisp/tools/logs.mjs
··· 1 + #!/usr/bin/env node 2 + // KidLisp Log Viewer (admin-only) 3 + // Pulls client-side telemetry from /api/kidlisp-log and /api/boot-log 4 + // 5 + // Auth: set AC_ADMIN_TOKEN env var or pass --token <bearer-token> 6 + // 7 + // Usage: 8 + // ./logs.mjs # recent kidlisp logs (default 20) 9 + // ./logs.mjs --limit 50 # last 50 entries 10 + // ./logs.mjs --type gpu-disabled # filter by log type 11 + // ./logs.mjs --since 2026-03-20 # logs since date 12 + // ./logs.mjs --boots # boot telemetry instead 13 + // ./logs.mjs --boots --errors # only boot errors 14 + // ./logs.mjs --stats # type breakdown counts 15 + // ./logs.mjs --token <token> # pass auth token inline 16 + 17 + const BASE = process.env.AC_API_URL || "https://aesthetic.computer"; 18 + 19 + const args = process.argv.slice(2); 20 + function flag(name) { 21 + return args.includes(`--${name}`); 22 + } 23 + function opt(name, fallback) { 24 + const i = args.indexOf(`--${name}`); 25 + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; 26 + } 27 + 28 + const token = opt("token", null) || process.env.AC_ADMIN_TOKEN; 29 + if (!token) { 30 + console.error( 31 + "Admin token required. Set AC_ADMIN_TOKEN or pass --token <bearer-token>", 32 + ); 33 + process.exit(1); 34 + } 35 + 36 + const authHeaders = { Authorization: `Bearer ${token}` }; 37 + 38 + async function main() { 39 + if (flag("boots")) { 40 + await fetchBoots(); 41 + } else { 42 + await fetchKidlispLogs(); 43 + } 44 + } 45 + 46 + async function fetchKidlispLogs() { 47 + const limit = opt("limit", "20"); 48 + const type = opt("type", null); 49 + const since = opt("since", null); 50 + const ua = opt("ua", null); 51 + 52 + const params = new URLSearchParams({ limit }); 53 + if (type) params.set("type", type); 54 + if (since) params.set("since", since); 55 + if (ua) params.set("ua", ua); 56 + 57 + const url = `${BASE}/api/kidlisp-log?${params}`; 58 + const res = await fetch(url, { headers: authHeaders }); 59 + if (!res.ok) { 60 + console.error(`Error ${res.status}: ${await res.text()}`); 61 + process.exit(1); 62 + } 63 + 64 + const { logs, stats } = await res.json(); 65 + 66 + if (flag("stats")) { 67 + console.log("\n Type breakdown:"); 68 + for (const s of stats) { 69 + console.log(` ${s._id}: ${s.count}`); 70 + } 71 + console.log(); 72 + return; 73 + } 74 + 75 + if (logs.length === 0) { 76 + console.log("No logs found."); 77 + return; 78 + } 79 + 80 + for (const log of logs) { 81 + const date = new Date(log.createdAt).toLocaleString(); 82 + const device = log.device?.userAgent?.slice(0, 60) || "unknown"; 83 + const country = log.server?.country || "??"; 84 + const detail = log.detail 85 + ? typeof log.detail === "object" 86 + ? JSON.stringify(log.detail) 87 + : log.detail 88 + : ""; 89 + console.log( 90 + `[${date}] ${log.type || "?"} ${country} ${log.effect || ""} ${detail}`, 91 + ); 92 + console.log(` device: ${device}`); 93 + if (log.gpuStatus) { 94 + const failed = Object.entries(log.gpuStatus) 95 + .filter(([, v]) => v > 0) 96 + .map(([k, v]) => `${k}:${v}`); 97 + if (failed.length) console.log(` gpu failures: ${failed.join(", ")}`); 98 + } 99 + } 100 + console.log(`\n Showing ${logs.length} entries.`); 101 + } 102 + 103 + async function fetchBoots() { 104 + const limit = opt("limit", "30"); 105 + const params = new URLSearchParams({ limit }); 106 + 107 + const url = `${BASE}/api/boot-log?${params}`; 108 + const res = await fetch(url, { headers: authHeaders }); 109 + if (!res.ok) { 110 + console.error(`Error ${res.status}: ${await res.text()}`); 111 + process.exit(1); 112 + } 113 + 114 + const { boots } = await res.json(); 115 + 116 + const errorsOnly = flag("errors"); 117 + const filtered = errorsOnly 118 + ? boots.filter((b) => b.status === "error") 119 + : boots; 120 + 121 + if (filtered.length === 0) { 122 + console.log(errorsOnly ? "No boot errors found." : "No boot logs found."); 123 + return; 124 + } 125 + 126 + for (const boot of filtered) { 127 + const date = new Date(boot.createdAt).toLocaleString(); 128 + const status = boot.status || "?"; 129 + const host = boot.meta?.host || "?"; 130 + const path = boot.meta?.path || "/"; 131 + const country = boot.server?.country || "??"; 132 + 133 + const icon = 134 + status === "error" ? "X" : status === "success" ? "+" : "-"; 135 + 136 + console.log(`[${icon}] ${date} ${status} ${country} ${host}${path}`); 137 + 138 + if (boot.error) { 139 + const errMsg = 140 + typeof boot.error === "string" 141 + ? boot.error 142 + : boot.error.message || JSON.stringify(boot.error).slice(0, 120); 143 + console.log(` error: ${errMsg}`); 144 + } 145 + 146 + if (boot.events?.length) { 147 + const errEvents = boot.events.filter( 148 + (e) => e.level === "error" || e.label?.includes("error"), 149 + ); 150 + for (const ev of errEvents) { 151 + console.log(` event: ${ev.label || ev.message || JSON.stringify(ev)}`); 152 + } 153 + } 154 + } 155 + console.log(`\n Showing ${filtered.length} of ${boots.length} entries.`); 156 + } 157 + 158 + main().catch((err) => { 159 + console.error(err); 160 + process.exit(1); 161 + });
+6
system/netlify/functions/boot-log.mjs
··· 2 2 // POST /api/boot-log 3 3 4 4 import { connect } from "../../backend/database.mjs"; 5 + import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 5 6 import { respond } from "../../backend/http.mjs"; 6 7 7 8 export async function handler(event) { ··· 10 11 } 11 12 12 13 if (event.httpMethod === "GET") { 14 + // Admin-only: require auth 15 + const user = await authorize(event.headers); 16 + if (!user) return respond(401, { error: "Authentication required" }); 17 + if (!(await hasAdmin(user))) return respond(403, { error: "Admin access required" }); 18 + 13 19 try { 14 20 const database = await connect(); 15 21 const boots = database.db.collection("boots");
+11 -1
system/netlify/functions/kidlisp-log.mjs
··· 3 3 // GET /api/kidlisp-log — query recent logs (with optional filters) 4 4 5 5 import { connect } from "../../backend/database.mjs"; 6 + import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 6 7 import { respond } from "../../backend/http.mjs"; 7 8 8 9 const COLLECTION = "kidlisp-logs"; ··· 13 14 } 14 15 15 16 if (event.httpMethod === "GET") { 17 + // Admin-only: require auth 18 + const user = await authorize(event.headers); 19 + if (!user) return respond(401, { error: "Authentication required" }); 20 + if (!(await hasAdmin(user))) return respond(403, { error: "Admin access required" }); 21 + 16 22 try { 17 23 const database = await connect(); 18 24 const col = database.db.collection(COLLECTION); ··· 45 51 } 46 52 47 53 if (event.httpMethod === "DELETE") { 48 - // Purge logs (for silo admin use) 54 + // Admin-only: require auth 55 + const delUser = await authorize(event.headers); 56 + if (!delUser) return respond(401, { error: "Authentication required" }); 57 + if (!(await hasAdmin(delUser))) return respond(403, { error: "Admin access required" }); 58 + 49 59 try { 50 60 const database = await connect(); 51 61 const col = database.db.collection(COLLECTION);
+15 -2
system/public/aesthetic.computer/lib/kidlisp.mjs
··· 7079 7079 // For the first argument (image source), support unquoted URLs, paths, #codes, and @handles 7080 7080 if (index === 0 && typeof arg === "string" && arg) { 7081 7081 // If it's a URL (http/https), path, #code, or @handle, treat it as unquoted 7082 - if (arg.startsWith("http://") || arg.startsWith("https://") || 7082 + if (arg.startsWith("http://") || arg.startsWith("https://") || 7083 7083 arg.includes("/") || arg.includes("@") || arg.startsWith("#")) { 7084 7084 return arg; // Return as-is for URLs, paths, #codes, and @handle/timestamp patterns 7085 7085 } 7086 7086 } 7087 7087 // Evaluate expressions for non-string arguments (like math expressions) 7088 - return this.evaluate(arg, api, this.localEnv); 7088 + const result = this.evaluate(arg, api, this.localEnv); 7089 + // Guard: if first arg evaluated to a function (e.g. builtin name 7090 + // like "circle"), treat the raw string as an image code instead. 7091 + if (index === 0 && typeof result === "function") { 7092 + return typeof arg === "string" ? arg : result; 7093 + } 7094 + return result; 7089 7095 }); 7096 + 7097 + // Bail out if the image source is invalid (not a string or bitmap) 7098 + if (processedArgs.length > 0 && typeof processedArgs[0] !== "string" && 7099 + (typeof processedArgs[0] !== "object" || !processedArgs[0]?.width)) { 7100 + console.warn("⚠️ stamp: invalid image source", processedArgs[0]); 7101 + return; 7102 + } 7090 7103 7091 7104 const performStamp = () => { 7092 7105 api.stamp(...processedArgs);