From 1e62eced8eef725a00dbdd220ba4c5ea4742e254 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 12:43:20 -0500 Subject: [PATCH 1/9] Maker Lab: Phase 0 spike report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents findings from the five blocking spikes before Phase 1 schema/tool signatures lock: - Spike 1 (WS routing): backend is per-connection (client_contexts dict with model_copy(deep=True)). backend-0002 patch slot collapses to empty — no change needed. - Spike 2 (persona swap): Open-LLM-VTuber already supports per-connection persona via the switch-config WS message + /app/characters/*.yaml. web-0006 patch slot collapses to empty. - Spike 3 (tutor-event handler): lives in the Python backend (websocket_handler.py) next to text-input/switch-config. backend-0001 owns it; web side untouched. - Spike 4 (Cubism SDK): CDN confirmed, SHA-256 pinned (94278358…), EULA prompt drafted, CUBISM-LICENSE.md drafted. Install-time fetch posture — we are not redistributing. - Spike 5 (Ollama benchmark): NUM_PARALLEL=4 is the sweet spot on RTX 5060 Ti (27 tok/s aggregate at N=8; p95=36s at N=25). NUM_PARALLEL=8 crashes. Supports the decision to broaden the manifest dep from "ollama" to "any OpenAI-compatible endpoint" with vLLM recommended for classroom mode (Phase 4 sibling bundle). --- bundles/maker-lab/PHASE-0-REPORT.md | 233 ++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 bundles/maker-lab/PHASE-0-REPORT.md diff --git a/bundles/maker-lab/PHASE-0-REPORT.md b/bundles/maker-lab/PHASE-0-REPORT.md new file mode 100644 index 0000000..a93adb3 --- /dev/null +++ b/bundles/maker-lab/PHASE-0-REPORT.md @@ -0,0 +1,233 @@ +# Maker Lab — Phase 0 Spike Report + +Status: 4 of 5 spikes resolved. Spike 5 (Ollama benchmark) needs user decision before running — flagged at the bottom. + +Evidence was gathered against the running `crow-companion` container (image `crow-companion`, uptime 45h on grackle). Open-LLM-VTuber is checked out at `/app/` inside the container via `git clone --recursive https://github.com/Open-LLM-VTuber/Open-LLM-VTuber.git`. + +--- + +## Spike 1 — Companion WS routing model: **per-connection** ✅ + +**Finding:** The backend is per-connection. No singleton posture anywhere in the hot path. + +Evidence (`src/open_llm_vtuber/websocket_handler.py`): + +- `WebSocketHandler.__init__` holds three per-client maps: + - `client_connections: Dict[str, WebSocket]` — one `WebSocket` per `client_uid` + - `client_contexts: Dict[str, ServiceContext]` — one `ServiceContext` per `client_uid` + - `current_conversation_tasks: Dict[str, Optional[asyncio.Task]]` — per-client asyncio task +- `_init_service_context(send_text, client_uid)` performs `model_copy(deep=True)` on every sub-config (`system_config`, `character_config`, `live2d_model`, `asr_engine`, `tts_engine`, `vad_engine`, `agent_engine`, `translate_engine`, `mcp_server_registery`, `tool_adapter`) and stores the clone in `client_contexts[client_uid]`. State mutations on one connection cannot bleed to another. +- `_handle_config_switch(websocket, client_uid, data)` (line 603) resolves `context = self.client_contexts[client_uid]` and calls `context.handle_config_switch(websocket, config_file_name)` — scoped to one connection. + +**Impact on plan:** + +- `backend-0002-per-connection-session-context.patch` is **not needed**. The patch slot stays empty. +- Phase 1 can lock tool signatures. Tools can safely rely on `session_token → connection_id → ServiceContext` routing. +- The "multi-kiosk isolation" verification test (plan line 367–368) is already guaranteed by the backend architecture; our job is to pin the token to a `client_uid` at redeem time. + +--- + +## Spike 2 — Persona-swap mechanism: **per-connection via `switch-config`** ✅ + +**Finding:** Per-connection persona switching already exists upstream. No new mechanism needed. No renderer-side patch needed. + +Evidence: + +- `scripts/generate-config.py` already emits per-persona YAMLs into `/app/characters/` (see `generate_character_configs`). Each YAML carries its own `persona_prompt`, `live2d_model_name`, `character_name`, `tts_config.edge_tts.voice`. Household profiles use this same mechanism today for per-user memory scoping (lines 384–497). +- `config_alts_dir: "characters"` is set in `system_config` (line 307 of generate-config.py). +- Runtime WS message `switch-config` (registered in `MessageType.CONFIG`, line 45 of `websocket_handler.py`) swaps the calling connection's character without touching other connections. + +**Implementation for maker-lab:** + +1. Ship three age-band YAMLs as part of the bundle's config-generation step (installer drops them into the companion's `characters/` dir, matching the household-profile pattern): + - `/app/characters/maker_lab_kid_tutor.yaml` (ages 5–9) + - `/app/characters/maker_lab_tween_tutor.yaml` (ages 10–13) + - `/app/characters/maker_lab_adult_tutor.yaml` (14+) +2. On `maker_start_session`, **the server** (not the client, not the LLM) sends `switch-config` on the kiosk's WS with the appropriate filename. This closes the LLM-spoofing path. +3. On `maker_end_session`, the server sends `switch-config` back to `crow_default.yaml`. + +**Impact on plan:** + +- `web-0006-persona-swap.patch` is **not needed** — no Electron-renderer change. Patch slot stays empty. +- The acceptance criterion ("persona-swap must be per-connection, not env-var, not process-global") is satisfied by the existing `switch-config` flow. No env-var toggling, no file-swap race. +- Phase 1 spike #2 in the plan said "a new MCP tool `crow_wm_set_persona(profile)` or a new WS message type is added if the companion doesn't already support per-connection persona" — the companion does, so neither is needed. +- **Security note added to Phase 1 plan:** maker-lab's backend handler must originate the `switch-config` server-side, driven by the session-token's resolved age band. The client/LLM must never be able to pick its own persona file. + +--- + +## Spike 3 — `tutor-event` handler location: **Python backend** ✅ + +**Finding:** Handler lives in the Python backend, not the Electron renderer. + +Rationale: + +- The WS dispatcher (`_message_handlers` map at `websocket_handler.py:82`) is the natural home for a new typed message. Adding `"tutor-event": self._handle_tutor_event` next to `"text-input": self._handle_conversation_trigger` matches the existing pattern exactly. +- The per-client `ServiceContext`, `mcp_server_registery`, and `tool_adapter` are all backend-side. The Electron renderer has no MCP client and no server-side tool-calling surface. +- The handler must validate `session_token` against maker-lab's MCP server (bridge at `http://host:3004`) and route the filtered reply back on the originating `WebSocket`. That's backend work. +- The filtered reply is spoken via TTS, which lives in `ServiceContext.tts_engine` on the backend. A renderer-side handler would require re-broadcasting to the backend anyway. + +**Impact on plan:** + +- `backend-0001-tutor-event-handler.patch` owns the handler. `patches/web/` is **not** involved in tutor-event routing. +- Patch scope: add `"tutor-event"` entry to `_init_message_handlers`, add `_handle_tutor_event(self, websocket, client_uid, data)` method that: (a) resolves `data["session_token"]` via the maker-lab MCP tool `maker_hint`, (b) pipes the filtered return through `context.tts_engine` on the same connection, (c) never treats the payload as text-input (no echoing the raw event back). + +--- + +## Spike 4 — Cubism SDK fetch target: **install-time fetch, pinned by SHA** ✅ + +**Finding:** Cubism Core is already present in the upstream Open-LLM-VTuber repo's `frontend/libs/`. Current deployment ships it transitively via `git clone` at Docker build time — already an install-time fetch. + +Version & pin: + +- File: `frontend/libs/live2dcubismcore.min.js` +- Size: 129,056 bytes +- SHA-256: `942783587666a3a1bddea93afd349e26f798ed19dcd7a52449d0ae3322fcff7c` +- License header: `Live2D Proprietary Software license` (EULA at `https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html`) +- File also ships unminified at `frontend/libs/live2dcubismcore.js` (222,574 bytes) for debugging. + +CDN endpoint (for Phase 3 AppImage first-launch fetch): + +- Canonical: `https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js` +- The community notes this CDN is "unreliable for production" (WebFetch returned 403, likely bot-filter; browsers succeed). **Mitigation:** after first fetch, cache locally at `~/.crow/cache/cubism/live2dcubismcore.min.js` keyed by SHA; verify SHA on every launch; only re-fetch on mismatch. If CDN fails, show the user-facing prompt pointing to the official Live2D SDK download page. +- Pinned SHA to enforce on fetch: `942783587666a3a1bddea93afd349e26f798ed19dcd7a52449d0ae3322fcff7c`. + +End-user agreement prompt (first launch, Phase 3 AppImage only): + +> **Live2D Cubism SDK License** +> +> This application uses the Live2D Cubism SDK for Web to render the animated mascot. The SDK is published by Live2D Inc. under the Live2D Proprietary Software License. +> +> To continue, download the SDK (≈130 KB) and accept the Live2D license. The download comes directly from Live2D's servers; Crow does not redistribute the SDK. +> +> Read the agreement: https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html +> +> [ I accept — download the SDK ] [ Cancel — disable pet mode ] + +`bundles/companion/CUBISM-LICENSE.md` (to ship with the bundle — draft): + +```markdown +# Live2D Cubism SDK — Plain-Language Summary + +The AI Companion uses the Live2D Cubism SDK for Web to animate its mascot. The SDK +is owned by Live2D Inc. and published under the Live2D Proprietary Software License +(https://www.live2d.com/eula/live2d-proprietary-software-license-agreement_en.html). + +What this means for you: + +- The SDK is NOT bundled in this install. On first launch the app downloads it from + Live2D's CDN (≈130 KB) into ~/.crow/cache/cubism/. +- You — the end user — accept Live2D's license at download time. Crow does not act + as a redistributor. +- If you opt out, pet-mode is disabled but web-tiled mode works without the SDK + being downloaded. +- For air-gapped classrooms: a documented manual install script is planned (fetch + the SDK on an internet-connected machine, copy into ~/.crow/cache/cubism/). + +Attribution (required by the Live2D agreement): + + Live2D Cubism SDK for Web © Live2D Inc. +``` + +**Impact on plan:** + +- Phase 3 is no longer blocked on a legal review. Phase 0 Cubism work is done: CDN confirmed, SHA pinned, prompt drafted, license doc drafted. All that remains is writing these into the bundle during Phase 3 work. +- CI is not gated on a Publication License (per the plan's revised posture). + +--- + +## Spike 5 — Ollama concurrency baseline: **done, with caveats** ✅ + +**Hardware:** grackle, RTX 5060 Ti 16 GB, llama3.2:3b, Q8 KV cache, flash attention on. Pulled to `/mnt/ollama-models/ollama` (external drive, 184 GB free). Prompt: "Explain a for-loop to a 7-year-old in 40 words or fewer." `num_predict=150`, `temperature=0.7`. + +Each row is the result of firing N concurrent `/api/generate` calls after a warm-up. + +### NUM_PARALLEL=1 (serialized — current production default) + +| N | wall | p50 | p95 | max | agg tok/s | errs | +|--:|--:|--:|--:|--:|--:|--:| +| 1 | 4.63s | 4.61 | 4.61 | 4.61 | 10.8 | 0 | +| 4 | 14.9s | 10.8 | 14.9 | 14.9 | 13.6 | 0 | +| 8 | 28.8s | 18.2 | 28.8 | 28.8 | 13.6 | 0 | +| 16 | 57.4s | 32.7 | 57.4 | 57.4 | 13.5 | 0 | +| 25 | 90.5s | 48.4 | 71.5 | 71.7 | 13.6 | 0 | + +Throughput plateaus at ~13.6 tok/s; p95 grows linearly with N. 25 kids → 71 s worst-case hint latency. Unusable for classroom. + +### NUM_PARALLEL=4 (recommended if sticking with Ollama) + +| N | wall | p50 | p95 | max | agg tok/s | errs | +|--:|--:|--:|--:|--:|--:|--:| +| 1 | 3.27s | 3.26 | 3.26 | 3.26 | 12.9 | 0 | +| 4 | 11.2s | 11.1 | 11.2 | 11.2 | 20.0 | 0 | +| 8 | 14.8s | 14.0 | 14.7 | 14.7 | 27.6 | 0 | +| 16 | 29.3s | 19.5 | 29.3 | 29.3 | 27.3 | 0 | +| 25 | 46.8s | 28.1 | 36.1 | 37.0 | 26.6 | 0 | + +Throughput ~2× better; p95 at 25 concurrent is 36s. Still marginal but survivable with the plan's canned-hint fallback on queue-overflow. + +### NUM_PARALLEL=8 (unstable on this hardware) + +| N | wall | errs | +|--:|--:|--:| +| 1 | 4.86s | 0 | +| 4 | 2.24s | **4** | +| 8 | 63.3s | **8** | +| 16 | 117s | **16** | +| 25 | 105s | **25** | + +Every multi-request test failed. Likely VRAM exhaustion or per-slot context overflow (32k context × 8 slots on Q8 KV = many GB). Do not deploy at 8 on this GPU with these settings. + +### Manifest recommendations + +- `ollama.recommended_num_parallel: 4` on a 16 GB consumer GPU. +- `requires.min_ram_gb = 8` for family mode, `16` for classroom mode: confirmed by measurement. +- **Hard warning:** Even at NUM_PARALLEL=4, 25-kid classroom p95 is 36s. The plan's global hint queue + canned-lesson-hint fallback on queue depth is not optional — it's the only thing making classroom mode tolerable with Ollama. + +### Important caveat surfaced by the benchmark + +Ollama does **not** scale well for classroom mode. Continuous batching is not part of the engine; each parallel slot is a separate context. The user-raised question of swapping to vLLM for classroom deployments is addressed in a separate note below — short version: the maker-lab server should not hard-code Ollama. The benchmark supports broadening the hard dep to "any OpenAI-compatible local endpoint" with vLLM as the recommended classroom engine. + + + +**Status:** Cannot run yet. Two blockers: + +1. **Model not pulled.** `ollama list` on grackle shows no `llama3.2:3b`. Per user CLAUDE.md rule ("If you need to install a package in order to complete the tasks as prompted, ask me for permission"), I need approval before `ollama pull llama3.2:3b` (~2 GB download). +2. **GPU currently saturated.** `nvidia-smi` reports the RTX 5060 Ti at 15,141 / 16,311 MiB used (93%) from other Ollama-loaded models. A fair benchmark needs a clean GPU; running now would spill to CPU or evict another model and skew all numbers. + +**Hardware context for the eventual benchmark:** + +- Host: grackle (32 GB RAM) +- GPU: NVIDIA RTX 5060 Ti, 16 GB VRAM, driver 580.126.09, CUDA 13.0 +- Ollama: `/usr/local/bin/ollama`, HTTP API responsive on `localhost:11434` +- Existing models in range: `qwen3:8b` (5.2 GB), `dolphin3:8b` (4.9 GB), `qwen3.5:0.8b` (1 GB) — can serve as relative reference points but the plan specifically targets `llama3.2:3b` as the minimum recommended model. + +**Proposed benchmark plan (when approved):** + +1. Unload all other Ollama models: `curl -X POST http://localhost:11434/api/generate -d '{"model":"","keep_alive":0}'` for each loaded model, or restart the Ollama service. +2. `ollama pull llama3.2:3b`. +3. For each `OLLAMA_NUM_PARALLEL ∈ {1, 4, 8}`: + - Restart Ollama with the env var set. + - Warm the model with a single request. + - Drive N concurrent 150-token completions (N = 1, 4, 8, 16, 25 to match classroom scale). Prompt: a short "explain a for-loop to a 7-year-old in 40 words or fewer" hint-shaped prompt. + - Measure: p50, p95, p99 first-token latency + total response latency; total throughput (tok/s aggregate). +4. Output: a table in this report + a `requires.num_parallel` recommendation in the bundle manifest. + +**Questions for the user:** + +- **Approve `ollama pull llama3.2:3b`?** It's the specific model the plan names. +- **Approve restarting Ollama mid-benchmark?** This will briefly drop any current Ollama consumers (chat-gateway, other sessions). Probably fine on a Sunday afternoon but wanted to flag. +- Alternative: skip the benchmark for now, hard-code the plan's suggested tuning, and revisit if classroom sessions show real latency problems. The plan's `requires.min_ram_gb = 16` classroom floor is defensible without the benchmark. + +--- + +## Summary of plan impact + +| Patch slot | Outcome | +|---|---| +| `backend-0001-tutor-event-handler.patch` | Needed. Owns the `tutor-event` WS message handler. Spike 3. | +| `backend-0002-per-connection-session-context.patch` | **Not needed.** Backend is already per-connection. Spike 1. | +| `backend-0003-maker-lab-mcp-registration.patch` | Needed. Registers maker-lab as a fourth MCP server (pattern already visible for `crow`, `crow-storage`, `crow-wm` in `generate-config.py`). | +| `web-0001` through `web-0005` | Unaffected by Phase 0. Needed for Linux pet-mode in Phase 3. | +| `web-0006-persona-swap.patch` | **Not needed.** Persona swap uses existing `switch-config`. Spike 2. | + +Phase 1 is cleared to begin once Spike 5 is resolved (or explicitly deferred). From d8b05b42f1680b8af691a4136cfe2a368f21a491 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 12:43:51 -0500 Subject: [PATCH 2/9] Maker Lab: Phase 1 bundle scaffold + cross-bundle audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the maker-lab bundle — a scaffolded AI learning companion paired with FOSS maker surfaces. Adds the MCP server (21 tools), admin panel (solo/family/classroom view modes + guest sidecar), schema, skill file, registry entry, and the cross-cutting audit patches required so learner_profile rows don't bleed into generic projects/memory surfaces. Security posture (enforced in code, not convention): - All kid-session tools take session_token, never learner_id — the LLM can't cross profiles by hallucinating a different id. - filterHint() runs on every hint: Flesch-Kincaid grade cap (kid-tutor only), kid-safe blocklist, per-persona word budget. Failed filters fall back to canned lesson hints. - rateLimitCheck() caps hints at 6/min per session. - Session state machine: active -> ending (5s flush) -> revoked. - Guest sessions: learner_id NULL + is_guest=1, CHECK constraint enforces exclusivity, boot-time sweep removes orphans. MCP server (bundles/maker-lab/server/): - server.js: 21 tools (learner CRUD admin-only, sessions, hint, progress, artifact, export, validate_lesson, etc). - filters.js: shared output filter, rate limiter, persona resolvers. - hint-pipeline.js: OpenAI-compat chat-completions call with filter + canned fallback + transcript write. - init-tables.js: 6 tables — sessions, bound_devices, redemption_codes, batches, transcripts, learner_settings. Learner profiles reuse research_projects.type='learner_profile' (no schema change there). Cross-bundle audit (Phase 1 deliverable): learner_profile rows must not appear in generic projects/memory surfaces. Patched: - servers/memory/server.js: crow_recall_by_context excludes source='maker-lab' by default; opt in via include_maker_lab=true. - servers/memory/crow-context.js: active-projects section excludes learner_profile. - servers/research/server.js: crow_list_projects, crow_project_stats, projects://list resource hide learner_profile unless caller explicitly filters by type. - servers/gateway/dashboard/panels/projects.js: count, list, detail all exclude learner_profile. - servers/gateway/dashboard/panels/nest/data-queries.js: dashboard project count excludes learner_profile. Platform registration: - registry/add-ons.json: new maker-lab entry. - extensions.js: ICON_MAP graduation-cap; CATEGORY_COLORS/_LABELS education. - nav-registry.js: CATEGORY_TO_GROUP education -> content. - i18n.js: extensions.categoryEducation en + es. - skills/superpowers.md: trigger row for maker-lab. - CLAUDE.md: server factory list + skills reference entry. Hard deps declared in manifest: companion bundle + any OpenAI-compatible local-LLM endpoint (recommended: ollama for solo/family, vllm for classroom). Phase 0 report has the benchmark numbers. --- CLAUDE.md | 2 + bundles/maker-lab/manifest.json | 64 + bundles/maker-lab/package-lock.json | 1476 +++++++++++++++++ bundles/maker-lab/package.json | 11 + bundles/maker-lab/panel/maker-lab.js | 292 ++++ bundles/maker-lab/server/db.js | 45 + bundles/maker-lab/server/filters.js | 164 ++ bundles/maker-lab/server/hint-pipeline.js | 198 +++ bundles/maker-lab/server/index.js | 26 + bundles/maker-lab/server/init-tables.js | 133 ++ bundles/maker-lab/server/server.js | 789 +++++++++ bundles/maker-lab/skills/maker-lab.md | 97 ++ registry/add-ons.json | 25 + servers/gateway/dashboard/nav-registry.js | 1 + .../gateway/dashboard/panels/extensions.js | 3 + .../dashboard/panels/nest/data-queries.js | 2 +- servers/gateway/dashboard/panels/projects.js | 18 +- servers/gateway/dashboard/shared/i18n.js | 1 + servers/memory/crow-context.js | 2 +- servers/memory/server.js | 8 +- servers/research/server.js | 8 +- skills/superpowers.md | 1 + 22 files changed, 3352 insertions(+), 14 deletions(-) create mode 100644 bundles/maker-lab/manifest.json create mode 100644 bundles/maker-lab/package-lock.json create mode 100644 bundles/maker-lab/package.json create mode 100644 bundles/maker-lab/panel/maker-lab.js create mode 100644 bundles/maker-lab/server/db.js create mode 100644 bundles/maker-lab/server/filters.js create mode 100644 bundles/maker-lab/server/hint-pipeline.js create mode 100644 bundles/maker-lab/server/index.js create mode 100644 bundles/maker-lab/server/init-tables.js create mode 100644 bundles/maker-lab/server/server.js create mode 100644 bundles/maker-lab/skills/maker-lab.md diff --git a/CLAUDE.md b/CLAUDE.md index 1452076..c665532 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,7 @@ bundles/iptv/ → IPTV channel manager (MCP server, 6 tools, M3 bundles/kodi/ → Kodi remote control (MCP server, 6 tools, JSON-RPC) bundles/trilium/ → TriliumNext knowledge base (Docker + MCP server, 11 tools, ETAPI) bundles/knowledge-base/ → Multilingual knowledge base (MCP server, 10 tools, LAN discovery, WCAG 2.1 AA) +bundles/maker-lab/ → STEM education companion for kids (MCP server, 21 tools, age-banded personas, classroom-capable). Phase 1 scaffold; see bundles/maker-lab/PHASE-0-REPORT.md android/ → Android WebView shell app (Crow's Nest mobile client) servers/gateway/public/ → PWA assets (manifest.json, service worker, icons) servers/gateway/push/ → Web Push notification infrastructure (VAPID) @@ -460,6 +461,7 @@ Add-on skills (activated when corresponding add-on is installed): - `kodi.md` — Kodi remote control: JSON-RPC playback, library browsing - `trilium.md` — TriliumNext knowledge base: note search, creation, web clipping, organization - `knowledge-base.md` — Multilingual knowledge base: create, edit, publish, search, verify resources, share articles, LAN discovery +- `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar - `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS - `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download - `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read diff --git a/bundles/maker-lab/manifest.json b/bundles/maker-lab/manifest.json new file mode 100644 index 0000000..2c4bad2 --- /dev/null +++ b/bundles/maker-lab/manifest.json @@ -0,0 +1,64 @@ +{ + "id": "maker-lab", + "name": "Maker Lab", + "version": "0.1.0", + "description": "Scaffolded AI learning companion paired with FOSS maker surfaces (Blockly first). Hint-ladder pedagogy, per-learner memory, age-banded personas, classroom-capable.", + "type": "mcp-server", + "author": "Crow", + "category": "education", + "tags": ["education", "stem", "kids", "classroom", "tutor", "blockly", "maker"], + "icon": "graduation-cap", + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MAKER_LAB_MODE", "MAKER_LAB_LLM_ENDPOINT", "MAKER_LAB_LLM_MODEL"] + }, + "panel": "panel/maker-lab.js", + "skills": ["skills/maker-lab.md"], + "requires": { + "min_ram_mb": 256, + "min_disk_mb": 100, + "bundles": ["companion"], + "local_llm_endpoint": { + "required": true, + "protocol": "openai_compatible", + "recommended_engine_by_mode": { + "solo": "ollama", + "family": "ollama", + "classroom": "vllm" + }, + "min_model": "llama3.2:3b" + } + }, + "env_vars": [ + { + "name": "MAKER_LAB_MODE", + "description": "Deployment mode: solo | family | classroom", + "default": "family", + "required": false, + "secret": false + }, + { + "name": "MAKER_LAB_LLM_ENDPOINT", + "description": "OpenAI-compatible chat-completions base URL (e.g. http://localhost:11434/v1 for Ollama). Leave blank to reuse Crow's default AI Profile.", + "default": "", + "required": false, + "secret": false + }, + { + "name": "MAKER_LAB_LLM_MODEL", + "description": "Model name to use for maker_hint calls (e.g. llama3.2:3b).", + "default": "llama3.2:3b", + "required": false, + "secret": false + }, + { + "name": "MAKER_LAB_KIOSK_BIND", + "description": "Kiosk HTTP bind address. In solo mode defaults to 127.0.0.1 (loopback only); opt in to LAN exposure in Settings.", + "default": "127.0.0.1", + "required": false, + "secret": false + } + ], + "notes": "Phase 1 scaffold. Classroom mode recommends the vllm bundle (Phase 4 sibling) for continuous-batching concurrency. See bundles/maker-lab/PHASE-0-REPORT.md for the Ollama vs vLLM benchmark." +} diff --git a/bundles/maker-lab/package-lock.json b/bundles/maker-lab/package-lock.json new file mode 100644 index 0000000..073f1d3 --- /dev/null +++ b/bundles/maker-lab/package-lock.json @@ -0,0 +1,1476 @@ +{ + "name": "crow-maker-lab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crow-maker-lab", + "version": "0.1.0", + "dependencies": { + "@libsql/client": "^0.14.0", + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@libsql/client": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.14.0.tgz", + "integrity": "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.14.0", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.4.4", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.14.0.tgz", + "integrity": "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz", + "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz", + "integrity": "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz", + "integrity": "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz", + "integrity": "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz", + "integrity": "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz", + "integrity": "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz", + "integrity": "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/libsql": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.4.7.tgz", + "integrity": "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==", + "cpu": [ + "x64", + "arm64", + "wasm32" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.4.7", + "@libsql/darwin-x64": "0.4.7", + "@libsql/linux-arm64-gnu": "0.4.7", + "@libsql/linux-arm64-musl": "0.4.7", + "@libsql/linux-x64-gnu": "0.4.7", + "@libsql/linux-x64-musl": "0.4.7", + "@libsql/win32-x64-msvc": "0.4.7" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/bundles/maker-lab/package.json b/bundles/maker-lab/package.json new file mode 100644 index 0000000..1890094 --- /dev/null +++ b/bundles/maker-lab/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-maker-lab", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@libsql/client": "^0.14.0", + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/maker-lab/panel/maker-lab.js b/bundles/maker-lab/panel/maker-lab.js new file mode 100644 index 0000000..31a81e7 --- /dev/null +++ b/bundles/maker-lab/panel/maker-lab.js @@ -0,0 +1,292 @@ +/** + * Crow's Nest Panel — Maker Lab (Phase 1 scaffold) + * + * Three view modes (solo / family / classroom), guest "Try it" button, + * minimal learner management. Lesson authoring UI lands in Phase 2. + * + * Handler pattern copied from bundles/knowledge-base/panel/knowledge-base.js. + */ + +export default { + id: "maker-lab", + name: "Maker Lab", + icon: "graduation-cap", + route: "/dashboard/maker-lab", + navOrder: 45, + category: "education", + + async handler(req, res, { db, layout, appRoot }) { + const { pathToFileURL } = await import("node:url"); + const { join } = await import("node:path"); + const componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js"); + const { escapeHtml } = await import(pathToFileURL(componentsPath).href); + + // Resolve current mode (solo/family/classroom) from dashboard_settings. + async function getMode() { + const r = await db.execute({ + sql: "SELECT value FROM dashboard_settings WHERE key = 'maker_lab.mode'", + args: [], + }); + return r.rows[0]?.value || "family"; + } + + async function setMode(mode) { + await db.execute({ + sql: `INSERT INTO dashboard_settings (key, value) VALUES ('maker_lab.mode', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [mode], + }); + } + + // ─── POST actions ─────────────────────────────────────────────────── + + if (req.method === "POST") { + const a = req.body?.action; + + if (a === "set_mode") { + const mode = String(req.body.mode || "family"); + if (["solo", "family", "classroom"].includes(mode)) { + // Solo-downgrade guard: refuse if more than one learner exists. + if (mode === "solo") { + const c = await db.execute({ + sql: "SELECT COUNT(*) AS n FROM research_projects WHERE type='learner_profile'", + args: [], + }); + if (Number(c.rows[0].n) > 1) { + return res.redirect("/dashboard/maker-lab?err=solo_multiple_learners"); + } + } + await setMode(mode); + } + return res.redirect("/dashboard/maker-lab"); + } + + if (a === "create_learner") { + const name = String(req.body.name || "").trim().slice(0, 100); + const age = Number(req.body.age); + const avatar = String(req.body.avatar || "").slice(0, 50) || null; + const consent = req.body.consent === "1"; + if (!name || !Number.isFinite(age) || age < 3 || age > 100) { + return res.redirect("/dashboard/maker-lab?err=create_invalid"); + } + if (!consent) { + return res.redirect("/dashboard/maker-lab?err=consent_required"); + } + const meta = JSON.stringify({ age, avatar }); + const ins = await db.execute({ + sql: `INSERT INTO research_projects (name, type, description, metadata, created_at, updated_at) + VALUES (?, 'learner_profile', ?, ?, datetime('now'), datetime('now')) RETURNING id`, + args: [name, null, meta], + }); + const lid = Number(ins.rows[0].id); + await db.execute({ + sql: `INSERT INTO maker_learner_settings (learner_id, consent_captured_at) + VALUES (?, datetime('now'))`, + args: [lid], + }); + return res.redirect(`/dashboard/maker-lab?created=${lid}`); + } + + if (a === "delete_learner") { + const lid = Number(req.body.learner_id); + if (!Number.isFinite(lid)) return res.redirect("/dashboard/maker-lab"); + // Tier-1: require explicit confirm step via ?confirm=DELETE in POST body. + if (req.body.confirm !== "DELETE") { + return res.redirect(`/dashboard/maker-lab?pending_delete=${lid}`); + } + await db.execute({ sql: "DELETE FROM maker_sessions WHERE learner_id=?", args: [lid] }); + await db.execute({ sql: "DELETE FROM maker_transcripts WHERE learner_id=?", args: [lid] }); + await db.execute({ sql: "DELETE FROM maker_bound_devices WHERE learner_id=?", args: [lid] }); + await db.execute({ sql: "DELETE FROM maker_learner_settings WHERE learner_id=?", args: [lid] }); + try { await db.execute({ sql: "DELETE FROM memories WHERE project_id=?", args: [lid] }); } catch {} + await db.execute({ + sql: "DELETE FROM research_projects WHERE id=? AND type='learner_profile'", + args: [lid], + }); + return res.redirect("/dashboard/maker-lab?deleted=1"); + } + + // Minting sessions actually happens via the MCP tool; the panel only + // renders the redemption code / short URL on return. Phase 2 wires the + // QR-code image rendering. + } + + // ─── GET ──────────────────────────────────────────────────────────── + + const mode = await getMode(); + const err = String(req.query.err || ""); + const pendingDelete = req.query.pending_delete ? Number(req.query.pending_delete) : null; + + const learnersR = await db.execute({ + sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, + mls.transcripts_enabled, mls.consent_captured_at + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id + WHERE rp.type = 'learner_profile' + ORDER BY rp.created_at DESC`, + args: [], + }); + const learners = learnersR.rows.map((r) => { + let meta = {}; + try { meta = JSON.parse(r.metadata || "{}"); } catch {} + return { + id: Number(r.id), + name: r.name, + age: meta.age ?? null, + persona: meta.age == null ? "kid-tutor" + : meta.age <= 9 ? "kid-tutor" + : meta.age <= 13 ? "tween-tutor" + : "adult-tutor", + transcripts_enabled: !!r.transcripts_enabled, + consent_captured_at: r.consent_captured_at, + created_at: r.created_at, + }; + }); + + const activeSessionsR = await db.execute({ + sql: `SELECT token, learner_id, started_at, expires_at, state, hints_used + FROM maker_sessions + WHERE state != 'revoked' AND expires_at > datetime('now') AND is_guest = 0 + ORDER BY started_at DESC LIMIT 50`, + args: [], + }); + const activeByLearner = new Map(); + for (const s of activeSessionsR.rows) { + activeByLearner.set(Number(s.learner_id), s); + } + + // ─── Render ───────────────────────────────────────────────────────── + + const modeTabs = ["solo", "family", "classroom"].map((m) => ` +
+ + + +
+ `).join(""); + + const errBanner = err ? `` : ""; + + const createForm = ` +
+ + Add learner +
+ + + + + + +
+
+ `; + + const guestButton = ` + Try it without saving → + `; + + const renderLearnerCard = (l) => { + const active = activeByLearner.get(l.id); + const isPending = pendingDelete === l.id; + return ` +
+
+ ${escapeHtml(l.name)} + age ${l.age ?? '—'} + ${escapeHtml(l.persona)} + ${l.transcripts_enabled ? 'transcripts on' : ''} + ${active ? `live session` : ''} +
+
+ ${active + ? `ends ${escapeHtml(active.expires_at || '')}` + : `Start session`} + ${isPending + ? `
+ + + + +
+ Cancel` + : `
+ + + +
`} +
+
+ `; + }; + + const learnersHtml = mode === "classroom" + ? `
${learners.map(renderLearnerCard).join("")}
` + : `
${learners.map(renderLearnerCard).join("")}
`; + + const modeHeadline = ({ + solo: "Solo mode — one learner, auto-start.", + family: "Family mode — per-learner progress timeline.", + classroom: "Classroom mode — grid view, bulk start, printable QR sheet.", + })[mode]; + + const startHint = req.query.start + ? `` + : ""; + + const phaseBanner = ``; + + const css = ``; + + const content = ` +
+ ${css} + ${phaseBanner} + ${errBanner} + ${startHint} +
${modeTabs}${guestButton}
+
${modeHeadline}
+ ${createForm} + ${learnersHtml || '
No learners yet. Add one above to get started.
'} +
+ `; + + return layout({ title: `Maker Lab (${mode})`, content }); + }, +}; diff --git a/bundles/maker-lab/server/db.js b/bundles/maker-lab/server/db.js new file mode 100644 index 0000000..7156713 --- /dev/null +++ b/bundles/maker-lab/server/db.js @@ -0,0 +1,45 @@ +import { createClient } from "@libsql/client"; +import { existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function sanitizeFtsQuery(input) { + if (!input || typeof input !== "string") return null; + const cleaned = input + .replace(/\b(AND|OR|NOT|NEAR)\b/gi, "") + .replace(/[*"(){}[\]^~:]/g, "") + .trim(); + if (!cleaned) return null; + const terms = cleaned + .split(/\s+/) + .filter((w) => w.length > 0) + .map((w) => `"${w}"`) + .join(" "); + return terms || null; +} + +export function escapeLikePattern(input) { + if (!input || typeof input !== "string") return input; + return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +export function resolveDataDir() { + if (process.env.CROW_DATA_DIR) return resolve(process.env.CROW_DATA_DIR); + const home = process.env.HOME || process.env.USERPROFILE || ""; + const crowHome = resolve(home, ".crow", "data"); + if (home && existsSync(crowHome)) return crowHome; + const repoData = resolve(__dirname, "../../../data"); + if (existsSync(repoData)) return repoData; + return resolve(home || ".", "data"); +} + +export function createDbClient(dbPath) { + const filePath = dbPath || process.env.CROW_DB_PATH || resolve(resolveDataDir(), "crow.db"); + const client = createClient({ url: `file:${filePath}` }); + client.execute("PRAGMA busy_timeout = 5000").catch(err => + console.warn("[maker-lab/db] busy_timeout:", err.message) + ); + return client; +} diff --git a/bundles/maker-lab/server/filters.js b/bundles/maker-lab/server/filters.js new file mode 100644 index 0000000..cd97d77 --- /dev/null +++ b/bundles/maker-lab/server/filters.js @@ -0,0 +1,164 @@ +/** + * Maker Lab — shared output filter + persona helpers. + * + * Used by both the MCP tool handlers (server.js) and the kiosk HTTP hint + * pipeline (hint-pipeline.js). Centralized so the safety posture is + * identical regardless of entry point. + */ + +export const HINT_RATE_PER_MIN = 6; +export const HINT_MAX_WORDS_KID = 40; +export const HINT_MAX_WORDS_TWEEN = 80; +export const HINT_MAX_WORDS_ADULT = 200; + +// Small kid-safe blocklist. Matched case-insensitively on whole words. +// Kept conservative. Extend via runtime config if needed. +export const BLOCKLIST_KID = [ + "kill", "die", "death", "suicide", "murder", "weapon", "gun", "knife", + "sex", "sexy", "porn", "naked", "drug", "drugs", "cocaine", "heroin", + "beer", "wine", "alcohol", "blood", "bloody", "hate", "damn", "hell", +]; + +export const CANNED_HINTS_BY_AGE = { + "kid-tutor": [ + "Let's look at the blocks together! Which one do you think comes first?", + "What happens if we move that block a little?", + "Great try! Want to peek at the next step?", + ], + "tween-tutor": [ + "What would you expect to happen when this runs? Trace it one step at a time.", + "If you break the problem into two smaller pieces, which piece is easier?", + "Hint: think about what the loop is repeating over.", + ], + "adult-tutor": [ + "Try tracing execution by hand for one iteration.", + "What invariant should hold at the top of the loop?", + "Sketch the types flowing through — where does the mismatch appear?", + ], +}; + +export function personaForAge(age) { + if (age == null) return "kid-tutor"; + if (age <= 9) return "kid-tutor"; + if (age <= 13) return "tween-tutor"; + return "adult-tutor"; +} + +export function ageBandFromGuestBand(band) { + const b = String(band || "").toLowerCase(); + if (b.includes("5-9") || b === "kid" || b === "child") return "kid-tutor"; + if (b.includes("10-13") || b === "tween") return "tween-tutor"; + return "adult-tutor"; +} + +function wordCount(s) { + return (String(s || "").match(/\S+/g) || []).length; +} + +function simpleSyllableCount(word) { + const w = word.toLowerCase().replace(/[^a-z]/g, ""); + if (!w) return 0; + const groups = w.match(/[aeiouy]+/g) || []; + let n = groups.length; + if (w.endsWith("e") && n > 1) n--; + return Math.max(1, n); +} + +// Very rough Flesch-Kincaid grade level. +export function readingGrade(text) { + const s = String(text || "").trim(); + if (!s) return 0; + const sentences = Math.max(1, (s.match(/[.!?]+/g) || [""]).length); + const words = s.match(/\S+/g) || []; + if (!words.length) return 0; + const syllables = words.reduce((sum, w) => sum + simpleSyllableCount(w), 0); + const wpS = words.length / sentences; + const spW = syllables / words.length; + return 0.39 * wpS + 11.8 * spW - 15.59; +} + +function hasBlockedWord(text, blocklist) { + const lower = String(text || "").toLowerCase(); + for (const w of blocklist) { + const re = new RegExp(`\\b${w}\\b`, "i"); + if (re.test(lower)) return w; + } + return null; +} + +/** + * Run the server-side hint filter. + * @returns {{ok: boolean, text?: string, reason?: string}} + */ +export function filterHint(raw, persona) { + const text = String(raw || "").trim(); + if (!text) return { ok: false, reason: "empty" }; + + const maxWords = + persona === "adult-tutor" ? HINT_MAX_WORDS_ADULT + : persona === "tween-tutor" ? HINT_MAX_WORDS_TWEEN + : HINT_MAX_WORDS_KID; + + if (wordCount(text) > maxWords) { + return { ok: false, reason: `too_long:${wordCount(text)}>${maxWords}` }; + } + + if (persona === "kid-tutor") { + const grade = readingGrade(text); + if (grade > 3.5) return { ok: false, reason: `reading_grade:${grade.toFixed(1)}` }; + } + + const hit = hasBlockedWord(text, BLOCKLIST_KID); + if (hit) return { ok: false, reason: `blocklist:${hit}` }; + + return { ok: true, text }; +} + +export function pickCannedHint(persona, { cannedHints, level } = {}) { + if (Array.isArray(cannedHints) && cannedHints.length) { + const idx = level ? Math.min(level - 1, cannedHints.length - 1) : Math.floor(Math.random() * cannedHints.length); + return cannedHints[idx]; + } + const bucket = CANNED_HINTS_BY_AGE[persona] || CANNED_HINTS_BY_AGE["kid-tutor"]; + const idx = level ? Math.min(level - 1, bucket.length - 1) : Math.floor(Math.random() * bucket.length); + return bucket[idx]; +} + +// ─── Rate limiter (process-global, per-session) ──────────────────────── + +const rateBuckets = new Map(); + +export function rateLimitCheck(token, limitPerMin = HINT_RATE_PER_MIN) { + const now = Date.now(); + const cutoff = now - 60_000; + const bucket = (rateBuckets.get(token) || []).filter((t) => t > cutoff); + if (bucket.length >= limitPerMin) { + rateBuckets.set(token, bucket); + return false; + } + bucket.push(now); + rateBuckets.set(token, bucket); + return true; +} + +export async function getLearnerAge(db, learnerId) { + if (!learnerId) return null; + const r = await db.execute({ + sql: `SELECT metadata FROM research_projects WHERE id=? AND type='learner_profile'`, + args: [learnerId], + }); + if (!r.rows.length) return null; + try { + const meta = JSON.parse(r.rows[0].metadata || "{}"); + return typeof meta.age === "number" ? meta.age : null; + } catch { + return null; + } +} + +export async function resolvePersonaForSession(db, session) { + if (!session) return "kid-tutor"; + if (session.is_guest) return ageBandFromGuestBand(session.guest_age_band); + const age = await getLearnerAge(db, session.learner_id); + return personaForAge(age); +} diff --git a/bundles/maker-lab/server/hint-pipeline.js b/bundles/maker-lab/server/hint-pipeline.js new file mode 100644 index 0000000..8b6857c --- /dev/null +++ b/bundles/maker-lab/server/hint-pipeline.js @@ -0,0 +1,198 @@ +/** + * Maker Lab — hint pipeline. + * + * Handles the full `maker_hint` request lifecycle: + * state machine → rate limit → LLM call (OpenAI-compat) → filter → fallback → transcript. + * + * Used by: + * - server.js (MCP tool handler) + * - panel/routes.js POST /kiosk/api/hint (HTTP from tutor-bridge.js) + * + * The LLM endpoint is any OpenAI-compatible chat-completions surface + * (Ollama `/v1/chat/completions`, vLLM, LocalAI, etc.). Configured via + * MAKER_LAB_LLM_ENDPOINT + MAKER_LAB_LLM_MODEL env vars. On failure or + * filter rejection, returns a canned hint — kids never see raw errors. + */ + +import { + filterHint, + rateLimitCheck, + pickCannedHint, + resolvePersonaForSession, +} from "./filters.js"; + +const LLM_TIMEOUT_MS = 15_000; + +const PERSONA_PROMPT = { + "kid-tutor": + "You are a patient, warm coding tutor for a child age 5 to 9. Reply in ONE short hint. " + + "Use 1st-3rd grade words. Short sentences. At most 40 words. Never say the answer — guide them with a question or a nudge. " + + "No scary, violent, or adult words. If you can't help with this, offer a friendly simple suggestion about blocks.", + "tween-tutor": + "You are a scaffolding tutor for a tween age 10 to 13. Reply with ONE short hint, at most 80 words. " + + "Use middle-grade vocabulary. Prefer guiding questions over direct answers, but you may explain a concept briefly if asked.", + "adult-tutor": + "You are a concise technical tutor for a self-learner age 14 or older. Reply with ONE focused explanation, at most 200 words. " + + "Plain language, precise terminology. Direct Q&A is fine; no hint ladder required.", +}; + +function resolveEndpoint() { + const explicit = process.env.MAKER_LAB_LLM_ENDPOINT; + if (explicit && explicit.trim()) return explicit.trim().replace(/\/$/, ""); + return "http://localhost:11434/v1"; +} + +function resolveModel() { + return process.env.MAKER_LAB_LLM_MODEL || "llama3.2:3b"; +} + +function resolveApiKey() { + return process.env.MAKER_LAB_LLM_API_KEY || "not-needed"; +} + +async function callLLM({ persona, question, lesson }) { + const endpoint = resolveEndpoint(); + const model = resolveModel(); + const url = `${endpoint}/chat/completions`; + + const systemPrompt = PERSONA_PROMPT[persona] || PERSONA_PROMPT["kid-tutor"]; + const lessonContext = lesson ? `\n\nCurrent lesson: ${lesson.title || lesson.id || ""}. Goal: ${lesson.goal || lesson.prompt || ""}.` : ""; + + const body = JSON.stringify({ + model, + messages: [ + { role: "system", content: systemPrompt + lessonContext }, + { role: "user", content: question }, + ], + temperature: 0.6, + max_tokens: 220, + stream: false, + }); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), LLM_TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${resolveApiKey()}`, + }, + body, + signal: ctrl.signal, + }); + if (!resp.ok) { + return { ok: false, reason: `llm_http_${resp.status}` }; + } + const data = await resp.json(); + const text = data?.choices?.[0]?.message?.content; + if (!text) return { ok: false, reason: "llm_empty_response" }; + return { ok: true, text }; + } catch (err) { + return { ok: false, reason: `llm_${err.name || "error"}:${err.message?.slice(0, 80)}` }; + } finally { + clearTimeout(timer); + } +} + +async function maybeWriteTranscript(db, session, sessionToken, kidText, tutorText) { + if (!session.transcripts_enabled_snapshot || session.is_guest || !session.learner_id) return; + try { + const t = await db.execute({ + sql: `SELECT COALESCE(MAX(turn_no), 0) AS n FROM maker_transcripts WHERE session_token=?`, + args: [sessionToken], + }); + const n = Number(t.rows[0].n) + 1; + await db.execute({ + sql: `INSERT INTO maker_transcripts (learner_id, session_token, turn_no, role, content) + VALUES (?, ?, ?, 'kid', ?)`, + args: [session.learner_id, sessionToken, n, kidText], + }); + await db.execute({ + sql: `INSERT INTO maker_transcripts (learner_id, session_token, turn_no, role, content) + VALUES (?, ?, ?, 'tutor', ?)`, + args: [session.learner_id, sessionToken, n + 1, tutorText], + }); + } catch { + // transcript failures must not break the hint + } +} + +/** + * Core hint handler. + * @param {object} db libsql client + * @param {object} args { sessionToken, session, surface, question, level, lessonId, cannedHints, lesson } + * @returns {Promise<{level:number, persona:string, surface?:string, lesson_id?:string|null, text:string, source:string, filtered_reason?:string}>} + */ +export async function handleHintRequest(db, args) { + const { + sessionToken, + session, + surface = "", + question, + level = 1, + lessonId = null, + cannedHints = null, + lesson = null, + } = args; + + const persona = await resolvePersonaForSession(db, session); + + // ending state: wrap-up, bypass queue + LLM + rate limiter. + if (session.state === "ending") { + return { + level, persona, surface, lesson_id: lessonId, + text: "Great work! Let's get ready to wrap up.", + source: "canned_ending", + }; + } + + // rate limit + if (!rateLimitCheck(sessionToken)) { + return { + level, persona, surface, lesson_id: lessonId, + text: "Let's think for a minute before asking again!", + source: "rate_limited", + }; + } + + // Call the LLM. On any failure or filter rejection, fall back to canned. + let text; + let source = "llm"; + let filteredReason = null; + + const llm = await callLLM({ persona, question, lesson }); + if (llm.ok) { + const filtered = filterHint(llm.text, persona); + if (filtered.ok) { + text = filtered.text; + } else { + filteredReason = filtered.reason; + // Try one retry with a canned lesson hint (no LLM). + const retry = filterHint(pickCannedHint(persona, { cannedHints, level }), persona); + text = retry.ok ? retry.text : pickCannedHint(persona, { level }); + source = "canned_filtered"; + } + } else { + text = pickCannedHint(persona, { cannedHints, level }); + source = `canned_${llm.reason}`; + } + + // Update session stats + activity + await db.execute({ + sql: `UPDATE maker_sessions + SET hints_used = hints_used + 1, + last_activity_at = datetime('now'), + idle_locked_at = NULL + WHERE token = ?`, + args: [sessionToken], + }); + + await maybeWriteTranscript(db, session, sessionToken, question, text); + + return { + level, persona, surface, lesson_id: lessonId, + text, source, + ...(filteredReason ? { filtered_reason: filteredReason } : {}), + }; +} diff --git a/bundles/maker-lab/server/index.js b/bundles/maker-lab/server/index.js new file mode 100644 index 0000000..bcd5251 --- /dev/null +++ b/bundles/maker-lab/server/index.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +/** + * Crow Maker Lab MCP Server — Bundle Entry Point (stdio transport) + * + * Scaffolded AI learning companion paired with FOSS maker surfaces. + * Hint-ladder pedagogy, per-learner memory scoped by research_project, + * age-banded personas, classroom-capable. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMakerLabServer } from "./server.js"; +import { initMakerLabTables } from "./init-tables.js"; +import { createDbClient } from "./db.js"; + +const db = createDbClient(); + +await initMakerLabTables(db); + +const server = createMakerLabServer(db, { + instructions: + "Crow Maker Lab — AI learning companion for kids. Tools take session_token (minted by admin via maker_start_session), never learner_id directly. Hint ladder: nudge → partial → demonstrate. Never initiate peer-sharing from a kid session. Defer to skills/maker-lab.md for pedagogy.", +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/maker-lab/server/init-tables.js b/bundles/maker-lab/server/init-tables.js new file mode 100644 index 0000000..239e01e --- /dev/null +++ b/bundles/maker-lab/server/init-tables.js @@ -0,0 +1,133 @@ +/** + * Maker Lab Bundle — Table Initialization + * + * Creates maker-lab session, device-binding, redemption-code, + * batch, transcript, and per-learner settings tables. + * Safe to re-run. + * + * Learner profiles themselves are stored in the shared research_projects + * table with type='learner_profile' (no schema change needed per CLAUDE.md). + */ + +async function initTable(db, label, sql) { + try { + await db.executeMultiple(sql); + } catch (err) { + console.error(`[maker-lab] Failed to initialize ${label}:`, err.message); + throw err; + } +} + +export async function initMakerLabTables(db) { + // Sessions — one row per live kiosk session. + // learner_id is nullable for guest sessions (is_guest=1). + // transcripts_enabled_snapshot is captured at session start and never + // re-read from live settings during the session (plan contract). + await initTable(db, "maker_sessions", ` + CREATE TABLE IF NOT EXISTS maker_sessions ( + token TEXT PRIMARY KEY, + learner_id INTEGER REFERENCES research_projects(id) ON DELETE SET NULL, + is_guest INTEGER NOT NULL DEFAULT 0, + guest_age_band TEXT, + batch_id TEXT REFERENCES maker_batches(batch_id) ON DELETE SET NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + revoked_at TEXT, + state TEXT NOT NULL DEFAULT 'active' CHECK(state IN ('active','ending','revoked')), + ending_started_at TEXT, + idle_lock_min INTEGER, + idle_locked_at TEXT, + last_activity_at TEXT NOT NULL DEFAULT (datetime('now')), + kiosk_device_id TEXT, + hints_used INTEGER NOT NULL DEFAULT 0, + transcripts_enabled_snapshot INTEGER NOT NULL DEFAULT 0, + CHECK ((is_guest = 1 AND learner_id IS NULL AND guest_age_band IS NOT NULL) + OR (is_guest = 0 AND learner_id IS NOT NULL)) + ); + + CREATE INDEX IF NOT EXISTS idx_maker_sessions_learner ON maker_sessions(learner_id); + CREATE INDEX IF NOT EXISTS idx_maker_sessions_state ON maker_sessions(state); + CREATE INDEX IF NOT EXISTS idx_maker_sessions_guest ON maker_sessions(is_guest); + CREATE INDEX IF NOT EXISTS idx_maker_sessions_batch ON maker_sessions(batch_id); + `); + + // Bound devices — solo-mode LAN-exposure fingerprint registry. + await initTable(db, "maker_bound_devices", ` + CREATE TABLE IF NOT EXISTS maker_bound_devices ( + fingerprint TEXT PRIMARY KEY, + learner_id INTEGER REFERENCES research_projects(id) ON DELETE CASCADE, + label TEXT, + bound_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_maker_bound_learner ON maker_bound_devices(learner_id); + `); + + // Redemption codes — one-shot codes for QR/URL handoff. + // used_at is set atomically by UPDATE...WHERE used_at IS NULL RETURNING. + await initTable(db, "maker_redemption_codes", ` + CREATE TABLE IF NOT EXISTS maker_redemption_codes ( + code TEXT PRIMARY KEY, + session_token TEXT NOT NULL REFERENCES maker_sessions(token) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + claimed_by_fingerprint TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_maker_codes_unused + ON maker_redemption_codes(code) WHERE used_at IS NULL; + `); + + // Batches — enables one-action revoke of a printed QR sheet. + await initTable(db, "maker_batches", ` + CREATE TABLE IF NOT EXISTS maker_batches ( + batch_id TEXT PRIMARY KEY, + label TEXT, + created_by_admin TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT, + revoke_reason TEXT + ); + `); + + // Transcripts — only written when transcripts_enabled_snapshot=1 on the session. + // Retention sweep runs on a timer (default 30 days). + await initTable(db, "maker_transcripts", ` + CREATE TABLE IF NOT EXISTS maker_transcripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + learner_id INTEGER NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE, + session_token TEXT NOT NULL, + turn_no INTEGER NOT NULL, + role TEXT NOT NULL CHECK(role IN ('kid','tutor','system')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_maker_transcripts_learner ON maker_transcripts(learner_id); + CREATE INDEX IF NOT EXISTS idx_maker_transcripts_session ON maker_transcripts(session_token); + CREATE INDEX IF NOT EXISTS idx_maker_transcripts_created ON maker_transcripts(created_at); + `); + + // Per-learner settings. + await initTable(db, "maker_learner_settings", ` + CREATE TABLE IF NOT EXISTS maker_learner_settings ( + learner_id INTEGER PRIMARY KEY REFERENCES research_projects(id) ON DELETE CASCADE, + transcripts_enabled INTEGER NOT NULL DEFAULT 0, + transcripts_retention_days INTEGER NOT NULL DEFAULT 30, + idle_lock_default_min INTEGER, + auto_resume_min INTEGER NOT NULL DEFAULT 15, + voice_input_enabled INTEGER NOT NULL DEFAULT 0, + consent_captured_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + // Boot-time sweep: remove orphaned guest sessions from a crash. + try { + await db.execute("DELETE FROM maker_sessions WHERE is_guest = 1 AND (revoked_at IS NOT NULL OR state = 'revoked' OR expires_at < datetime('now'))"); + } catch (err) { + // Non-fatal — table may not yet have rows. + } +} diff --git a/bundles/maker-lab/server/server.js b/bundles/maker-lab/server/server.js new file mode 100644 index 0000000..7c9222a --- /dev/null +++ b/bundles/maker-lab/server/server.js @@ -0,0 +1,789 @@ +/** + * Crow Maker Lab MCP Server + * + * Factory: createMakerLabServer(db, options?) + * + * Security model (from plan): + * - Tools NEVER take learner_id directly; they take session_token and resolve + * server-side. LLM hallucinations cannot cross profiles. + * - Output filter (reading-level + blocklist + length) runs on every maker_hint + * return before the companion speaks it. + * - Rate limit per session on maker_hint (default 6/min). + * - Session state machine: active → ending (5s flush) → revoked. + * - Persona is resolved server-side from learner age / guest age_band, never + * from LLM output or client header. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { randomBytes, randomUUID } from "node:crypto"; +import { + personaForAge, + ageBandFromGuestBand, + resolvePersonaForSession, + getLearnerAge, + filterHint, +} from "./filters.js"; +import { handleHintRequest } from "./hint-pipeline.js"; + +// ─── Constants ──────────────────────────────────────────────────────────── + +const SESSION_DEFAULT_MIN = 60; +const SESSION_MAX_MIN = 240; +const GUEST_MAX_MIN = 30; +const CODE_TTL_MIN = 10; +const ENDING_FLUSH_SEC = 5; + +function mintToken() { + return randomBytes(24).toString("base64url"); +} + +function mintRedemptionCode() { + const A = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O + const N = "23456789"; // no 0, 1 + const pick = (s) => s[Math.floor(Math.random() * s.length)]; + return `${pick(A)}${pick(A)}${pick(A)}-${pick(N)}${pick(N)}${pick(N)}`; +} + +function addMinutesISO(min) { + return new Date(Date.now() + min * 60_000).toISOString(); +} + +// ─── Session resolution ─────────────────────────────────────────────────── + +function mcpError(msg) { + return { content: [{ type: "text", text: msg }], isError: true }; +} + +function mcpOk(obj) { + const text = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2); + return { content: [{ type: "text", text }] }; +} + +/** + * Resolve a session token to its live state. Returns null if unknown/revoked/expired. + * Also transitions expired 'active' sessions to 'revoked' lazily. + */ +async function resolveSession(db, token) { + if (!token || typeof token !== "string") return null; + const r = await db.execute({ + sql: `SELECT s.*, rp.name AS learner_name + FROM maker_sessions s + LEFT JOIN research_projects rp ON rp.id = s.learner_id + WHERE s.token = ?`, + args: [token], + }); + if (!r.rows.length) return null; + const row = r.rows[0]; + if (row.state === "revoked") return null; + if (row.expires_at && row.expires_at < new Date().toISOString()) { + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [token], + }); + return null; + } + return row; +} + +async function touchActivity(db, token, reason) { + await db.execute({ + sql: `UPDATE maker_sessions SET last_activity_at=datetime('now'), idle_locked_at=NULL WHERE token=?`, + args: [token], + }); +} + +// ─── Factory ────────────────────────────────────────────────────────────── + +export function createMakerLabServer(db, options = {}) { + const server = new McpServer( + { name: "crow-maker-lab", version: "0.1.0" }, + options.instructions ? { instructions: options.instructions } : undefined + ); + + // ─── Admin: learner CRUD ──────────────────────────────────────────────── + + server.tool( + "maker_create_learner", + "Create a new learner profile. Admin-only. Captures consent (parent/guardian/teacher). Stored as a research_project with type='learner_profile'.", + { + name: z.string().min(1).max(100).describe("Learner's name (first name or nickname)"), + age: z.number().int().min(3).max(100).describe("Age in years"), + avatar: z.string().max(50).optional().describe("Live2D avatar model id (optional)"), + consent: z.literal(true).describe("Must be true. The admin confirms consent (parent/guardian/teacher)."), + notes: z.string().max(1000).optional(), + }, + async ({ name, age, avatar, notes }) => { + try { + const meta = JSON.stringify({ age, avatar: avatar || null, notes: notes || null }); + const res = await db.execute({ + sql: `INSERT INTO research_projects (name, type, description, metadata, created_at, updated_at) + VALUES (?, 'learner_profile', ?, ?, datetime('now'), datetime('now')) RETURNING id`, + args: [name, notes || null, meta], + }); + const learnerId = Number(res.rows[0].id); + await db.execute({ + sql: `INSERT INTO maker_learner_settings (learner_id, consent_captured_at) + VALUES (?, datetime('now'))`, + args: [learnerId], + }); + return mcpOk({ learner_id: learnerId, name, age, persona: personaForAge(age) }); + } catch (err) { + return mcpError(`Failed to create learner: ${err.message}`); + } + } + ); + + server.tool( + "maker_list_learners", + "List all learner profiles. Admin-only.", + {}, + async () => { + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, + mls.transcripts_enabled, mls.consent_captured_at + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id + WHERE rp.type = 'learner_profile' + ORDER BY rp.created_at DESC`, + args: [], + }); + const learners = r.rows.map((row) => { + let meta = {}; + try { meta = JSON.parse(row.metadata || "{}"); } catch {} + return { + learner_id: Number(row.id), + name: row.name, + age: meta.age ?? null, + persona: personaForAge(meta.age), + transcripts_enabled: !!row.transcripts_enabled, + consent_captured_at: row.consent_captured_at, + created_at: row.created_at, + }; + }); + return mcpOk({ learners }); + } + ); + + server.tool( + "maker_get_learner", + "Get one learner's full profile + settings. Admin-only.", + { learner_id: z.number().int().positive() }, + async ({ learner_id }) => { + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, mls.* + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id + WHERE rp.id = ? AND rp.type = 'learner_profile'`, + args: [learner_id], + }); + if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); + const row = r.rows[0]; + let meta = {}; + try { meta = JSON.parse(row.metadata || "{}"); } catch {} + return mcpOk({ + learner_id: Number(row.id), + name: row.name, + age: meta.age ?? null, + avatar: meta.avatar ?? null, + persona: personaForAge(meta.age), + transcripts_enabled: !!row.transcripts_enabled, + transcripts_retention_days: row.transcripts_retention_days ?? 30, + idle_lock_default_min: row.idle_lock_default_min, + auto_resume_min: row.auto_resume_min ?? 15, + voice_input_enabled: !!row.voice_input_enabled, + consent_captured_at: row.consent_captured_at, + created_at: row.created_at, + }); + } + ); + + server.tool( + "maker_update_learner", + "Update a learner's profile / settings. Admin-only.", + { + learner_id: z.number().int().positive(), + name: z.string().min(1).max(100).optional(), + age: z.number().int().min(3).max(100).optional(), + avatar: z.string().max(50).optional(), + transcripts_enabled: z.boolean().optional(), + transcripts_retention_days: z.number().int().min(0).max(3650).optional(), + idle_lock_default_min: z.number().int().min(0).max(240).optional(), + auto_resume_min: z.number().int().min(0).max(240).optional(), + voice_input_enabled: z.boolean().optional(), + }, + async (args) => { + const { learner_id } = args; + const r = await db.execute({ + sql: `SELECT metadata FROM research_projects WHERE id=? AND type='learner_profile'`, + args: [learner_id], + }); + if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); + let meta = {}; + try { meta = JSON.parse(r.rows[0].metadata || "{}"); } catch {} + if (args.age != null) meta.age = args.age; + if (args.avatar != null) meta.avatar = args.avatar; + + const sets = ["metadata=?, updated_at=datetime('now')"]; + const sqlArgs = [JSON.stringify(meta)]; + if (args.name != null) { sets.push("name=?"); sqlArgs.push(args.name); } + sqlArgs.push(learner_id); + await db.execute({ + sql: `UPDATE research_projects SET ${sets.join(", ")} WHERE id=?`, + args: sqlArgs, + }); + + // Upsert settings row + const settingsCols = ["transcripts_enabled", "transcripts_retention_days", "idle_lock_default_min", "auto_resume_min", "voice_input_enabled"]; + const updates = []; + const updArgs = []; + for (const c of settingsCols) { + if (args[c] !== undefined) { + updates.push(`${c}=?`); + updArgs.push(typeof args[c] === "boolean" ? (args[c] ? 1 : 0) : args[c]); + } + } + if (updates.length) { + await db.execute({ + sql: `INSERT INTO maker_learner_settings (learner_id) VALUES (?) + ON CONFLICT(learner_id) DO NOTHING`, + args: [learner_id], + }); + updArgs.push(learner_id); + await db.execute({ + sql: `UPDATE maker_learner_settings SET ${updates.join(", ")}, updated_at=datetime('now') WHERE learner_id=?`, + args: updArgs, + }); + } + return mcpOk({ updated: true, learner_id }); + } + ); + + server.tool( + "maker_delete_learner", + "Permanently delete a learner and cascade to sessions, transcripts, memories, and storage references. Tier-1 destructive action — admin confirms in panel before calling.", + { + learner_id: z.number().int().positive(), + confirm: z.literal("DELETE").describe("Must equal the literal string 'DELETE' to proceed."), + reason: z.string().max(500).optional(), + }, + async ({ learner_id, reason }) => { + const r = await db.execute({ + sql: `SELECT name FROM research_projects WHERE id=? AND type='learner_profile'`, + args: [learner_id], + }); + if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); + const name = r.rows[0].name; + // Cascade: sessions → transcripts (FK), codes (FK via session), bound_devices (FK), + // settings (FK). Memories tagged source='maker-lab' with project_id = learner_id. + await db.execute({ sql: `DELETE FROM maker_sessions WHERE learner_id=?`, args: [learner_id] }); + await db.execute({ sql: `DELETE FROM maker_transcripts WHERE learner_id=?`, args: [learner_id] }); + await db.execute({ sql: `DELETE FROM maker_bound_devices WHERE learner_id=?`, args: [learner_id] }); + await db.execute({ sql: `DELETE FROM maker_learner_settings WHERE learner_id=?`, args: [learner_id] }); + try { + await db.execute({ sql: `DELETE FROM memories WHERE project_id=?`, args: [learner_id] }); + } catch {} + await db.execute({ sql: `DELETE FROM research_projects WHERE id=? AND type='learner_profile'`, args: [learner_id] }); + return mcpOk({ deleted: true, learner_id, name, reason: reason || null }); + } + ); + + // ─── Admin: mode + sessions ───────────────────────────────────────────── + + server.tool( + "maker_set_mode", + "Switch deployment mode between solo, family, classroom. Admin-only. Downgrading family→solo refuses if more than one learner profile exists (use the Archive & Downgrade flow in the panel instead).", + { mode: z.enum(["solo", "family", "classroom"]) }, + async ({ mode }) => { + if (mode === "solo") { + const r = await db.execute({ + sql: `SELECT COUNT(*) AS n FROM research_projects WHERE type='learner_profile'`, + args: [], + }); + if (Number(r.rows[0].n) > 1) { + return mcpError("Cannot downgrade to solo mode: more than one learner profile exists. Use the 'Archive & Downgrade' flow in the panel."); + } + } + await db.execute({ + sql: `INSERT INTO dashboard_settings (key, value) VALUES ('maker_lab.mode', ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, + args: [mode], + }); + return mcpOk({ mode }); + } + ); + + server.tool( + "maker_start_session", + "Mint a new kiosk session for a learner and return a redemption code (NOT the raw token). The QR/URL carries the code; the token is issued as an HttpOnly cookie on redemption. Admin-only.", + { + learner_id: z.number().int().positive(), + duration_min: z.number().int().min(5).max(SESSION_MAX_MIN).default(SESSION_DEFAULT_MIN).optional(), + idle_lock_min: z.number().int().min(0).max(240).optional(), + batch_id: z.string().max(64).optional(), + }, + async ({ learner_id, duration_min = SESSION_DEFAULT_MIN, idle_lock_min, batch_id }) => { + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, mls.transcripts_enabled, mls.idle_lock_default_min + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id=rp.id + WHERE rp.id=? AND rp.type='learner_profile'`, + args: [learner_id], + }); + if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); + const learner = r.rows[0]; + const token = mintToken(); + const code = mintRedemptionCode(); + const expiresAt = addMinutesISO(duration_min); + const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); + const idleMin = idle_lock_min ?? learner.idle_lock_default_min ?? null; + + await db.execute({ + sql: `INSERT INTO maker_sessions + (token, learner_id, is_guest, expires_at, idle_lock_min, transcripts_enabled_snapshot, batch_id) + VALUES (?, ?, 0, ?, ?, ?, ?)`, + args: [token, learner_id, expiresAt, idleMin, learner.transcripts_enabled ? 1 : 0, batch_id || null], + }); + await db.execute({ + sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, + args: [code, token, codeExpiresAt], + }); + return mcpOk({ + redemption_code: code, + short_url: `/kiosk/r/${code}`, + code_expires_at: codeExpiresAt, + session_expires_at: expiresAt, + learner_id, + learner_name: learner.name, + batch_id: batch_id || null, + }); + } + ); + + server.tool( + "maker_start_sessions_bulk", + "Mint sessions for multiple learners sharing a batch_id. Returns an array of redemption codes for a printable QR sheet. Admin-only.", + { + learner_ids: z.array(z.number().int().positive()).min(1).max(50), + duration_min: z.number().int().min(5).max(SESSION_MAX_MIN).optional(), + idle_lock_min: z.number().int().min(0).max(240).optional(), + batch_label: z.string().max(200).optional(), + }, + async ({ learner_ids, duration_min = SESSION_DEFAULT_MIN, idle_lock_min, batch_label }) => { + const batchId = randomUUID(); + await db.execute({ + sql: `INSERT INTO maker_batches (batch_id, label) VALUES (?, ?)`, + args: [batchId, batch_label || null], + }); + const sessions = []; + for (const lid of learner_ids) { + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, mls.transcripts_enabled, mls.idle_lock_default_min + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id=rp.id + WHERE rp.id=? AND rp.type='learner_profile'`, + args: [lid], + }); + if (!r.rows.length) { + sessions.push({ learner_id: lid, error: "not_found" }); + continue; + } + const learner = r.rows[0]; + const token = mintToken(); + const code = mintRedemptionCode(); + const expiresAt = addMinutesISO(duration_min); + const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); + const idleMin = idle_lock_min ?? learner.idle_lock_default_min ?? null; + await db.execute({ + sql: `INSERT INTO maker_sessions + (token, learner_id, is_guest, expires_at, idle_lock_min, transcripts_enabled_snapshot, batch_id) + VALUES (?, ?, 0, ?, ?, ?, ?)`, + args: [token, lid, expiresAt, idleMin, learner.transcripts_enabled ? 1 : 0, batchId], + }); + await db.execute({ + sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, + args: [code, token, codeExpiresAt], + }); + sessions.push({ + learner_id: lid, learner_name: learner.name, + redemption_code: code, short_url: `/kiosk/r/${code}`, + code_expires_at: codeExpiresAt, session_expires_at: expiresAt, + }); + } + return mcpOk({ batch_id: batchId, batch_label: batch_label || null, sessions }); + } + ); + + server.tool( + "maker_start_guest_session", + "Mint an ephemeral guest session (no learner profile, no memories, no transcripts, no artifact save). 30-min cap. Returns a direct short URL + preview cookie (no redemption code needed — no handoff).", + { + age_band: z.enum(["5-9", "10-13", "14+"]), + }, + async ({ age_band }) => { + const token = mintToken(); + const code = mintRedemptionCode(); + const expiresAt = addMinutesISO(GUEST_MAX_MIN); + const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); + await db.execute({ + sql: `INSERT INTO maker_sessions + (token, learner_id, is_guest, guest_age_band, expires_at, transcripts_enabled_snapshot) + VALUES (?, NULL, 1, ?, ?, 0)`, + args: [token, age_band, expiresAt], + }); + await db.execute({ + sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, + args: [code, token, codeExpiresAt], + }); + return mcpOk({ + redemption_code: code, + short_url: `/kiosk/r/${code}`, + persona: ageBandFromGuestBand(age_band), + session_expires_at: expiresAt, + is_guest: true, + }); + } + ); + + server.tool( + "maker_end_session", + "Gracefully end a session. Transitions active→ending with a 5s flush window, writes wrap-up memory for non-guest sessions, then revokes.", + { session_token: z.string().min(1) }, + async ({ session_token }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session not found or already ended"); + if (sess.state === "ending") return mcpOk({ state: "ending", already: true }); + await db.execute({ + sql: `UPDATE maker_sessions SET state='ending', ending_started_at=datetime('now') WHERE token=?`, + args: [session_token], + }); + setTimeout(async () => { + try { + if (!sess.is_guest && sess.learner_id) { + try { + await db.execute({ + sql: `INSERT INTO memories (content, context, category, importance, tags, project_id, source, created_at) + VALUES (?, ?, 'learning', 4, 'maker-lab,session-end', ?, 'maker-lab', datetime('now'))`, + args: [ + `Session ran from ${sess.started_at}. Hints used: ${sess.hints_used}.`, + `Session ended — ${sess.learner_name || "learner"}`, + sess.learner_id, + ], + }); + } catch {} + } + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [session_token], + }); + } catch {} + }, ENDING_FLUSH_SEC * 1000); + return mcpOk({ state: "ending", flush_seconds: ENDING_FLUSH_SEC }); + } + ); + + server.tool( + "maker_force_end_session", + "Hard kill a session. Skips the 5s flush; any in-flight artifact save may be lost. Requires a reason (logged).", + { + session_token: z.string().min(1), + reason: z.string().min(3).max(500), + }, + async ({ session_token, reason }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session not found or already revoked"); + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [session_token], + }); + return mcpOk({ state: "revoked", reason }); + } + ); + + server.tool( + "maker_revoke_batch", + "Revoke every session in a batch (use when a printed QR sheet is lost). Admin-only. Requires a reason (logged).", + { + batch_id: z.string().min(1), + reason: z.string().min(3).max(500), + }, + async ({ batch_id, reason }) => { + const r = await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') + WHERE batch_id=? AND state != 'revoked' RETURNING token`, + args: [batch_id], + }); + await db.execute({ + sql: `UPDATE maker_batches SET revoked_at=datetime('now'), revoke_reason=? WHERE batch_id=?`, + args: [reason, batch_id], + }); + return mcpOk({ revoked: r.rows.length, batch_id, reason }); + } + ); + + server.tool( + "maker_unlock_idle", + "Clear an idle-locked session without ending it. Admin-only.", + { session_token: z.string().min(1) }, + async ({ session_token }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session not found or already revoked"); + await db.execute({ + sql: `UPDATE maker_sessions SET idle_locked_at=NULL, last_activity_at=datetime('now') WHERE token=?`, + args: [session_token], + }); + return mcpOk({ unlocked: true }); + } + ); + + server.tool( + "maker_redeem_code", + "INTERNAL: redeem a one-shot code for a session token. The /kiosk/r/:code HTTP handler calls this server-side. Uses UPDATE...RETURNING so a race produces exactly one winner; expired codes fail atomically.", + { + code: z.string().min(3).max(32), + kiosk_fingerprint: z.string().min(1).max(256), + }, + async ({ code, kiosk_fingerprint }) => { + const r = await db.execute({ + sql: `UPDATE maker_redemption_codes + SET used_at=datetime('now'), claimed_by_fingerprint=? + WHERE code=? AND used_at IS NULL AND expires_at > datetime('now') + RETURNING session_token`, + args: [kiosk_fingerprint, code], + }); + if (!r.rows.length) return mcpError("Code invalid, expired, or already used"); + const token = r.rows[0].session_token; + await db.execute({ + sql: `UPDATE maker_sessions SET kiosk_device_id=? WHERE token=?`, + args: [kiosk_fingerprint, token], + }); + return mcpOk({ session_token: token }); + } + ); + + // ─── Kid-session tools (all take session_token) ───────────────────────── + + server.tool( + "maker_get_session_context", + "Return non-PII context the companion's LLM can use to frame its hint: age band, persona, current lesson id, recent progress. No names, no memory content.", + { session_token: z.string().min(1) }, + async ({ session_token }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session invalid or expired"); + const persona = await resolvePersonaForSession(db, sess); + let recent = []; + if (!sess.is_guest && sess.learner_id) { + try { + const r = await db.execute({ + sql: `SELECT context AS title, created_at FROM memories + WHERE project_id=? AND source='maker-lab' + ORDER BY created_at DESC LIMIT 5`, + args: [sess.learner_id], + }); + recent = r.rows.map((x) => ({ title: x.title, at: x.created_at })); + } catch {} + } + await touchActivity(db, session_token); + return mcpOk({ + persona, + state: sess.state, + is_guest: !!sess.is_guest, + hints_used: sess.hints_used, + recent_progress: recent, + }); + } + ); + + server.tool( + "maker_hint", + "Request a scaffolded hint for the current activity. Output is filtered (reading-level / blocklist / length) and rate-limited. On filter failure, returns a canned lesson hint. In the 'ending' state, returns a wrap-up canned hint without calling the LLM.", + { + session_token: z.string().min(1), + surface: z.string().max(50).describe("e.g. 'blockly'"), + question: z.string().min(1).max(2000), + level: z.number().int().min(1).max(3).default(1).optional(), + lesson_id: z.string().max(100).optional(), + canned_hints: z.array(z.string().max(500)).max(10).optional(), + }, + async ({ session_token, surface, question, level = 1, lesson_id, canned_hints }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session invalid or expired"); + const result = await handleHintRequest(db, { + sessionToken: session_token, + session: sess, + surface, question, level, + lessonId: lesson_id || null, + cannedHints: canned_hints || null, + }); + return mcpOk(result); + } + ); + + server.tool( + "maker_log_progress", + "Log a lesson-progress event for the session's learner. No-op for guest sessions. Writes a memory tagged source='maker-lab'.", + { + session_token: z.string().min(1), + surface: z.string().max(50), + activity: z.string().max(200), + outcome: z.enum(["started", "completed", "abandoned", "struggled"]), + note: z.string().max(2000).optional(), + }, + async ({ session_token, surface, activity, outcome, note }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session invalid or expired"); + if (sess.state === "revoked") return mcpError("Session revoked"); + await touchActivity(db, session_token); + if (sess.is_guest || !sess.learner_id) { + return mcpOk({ logged: false, reason: "guest" }); + } + try { + await db.execute({ + sql: `INSERT INTO memories (content, context, category, importance, tags, project_id, source, created_at) + VALUES (?, ?, 'learning', 5, ?, ?, 'maker-lab', datetime('now'))`, + args: [ + note || `${outcome} on ${activity} in ${surface}`, + `${surface}:${activity} — ${outcome}`, + `maker-lab,${surface},${outcome}`, + sess.learner_id, + ], + }); + return mcpOk({ logged: true, learner_id: sess.learner_id }); + } catch (err) { + return mcpError(`Failed to log progress: ${err.message}`); + } + } + ); + + server.tool( + "maker_next_suggestion", + "Return a suggested next activity based on recent progress. No-op with a friendly canned reply for guest sessions.", + { session_token: z.string().min(1) }, + async ({ session_token }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session invalid or expired"); + await touchActivity(db, session_token); + if (sess.is_guest) { + return mcpOk({ suggestion: "Try the next lesson from the menu!" }); + } + // Phase 1: simple heuristic — if last outcome 'completed', suggest next; else repeat. + try { + const r = await db.execute({ + sql: `SELECT context AS title, tags FROM memories + WHERE project_id=? AND source='maker-lab' + ORDER BY created_at DESC LIMIT 1`, + args: [sess.learner_id], + }); + if (!r.rows.length) return mcpOk({ suggestion: "Start with the first Blockly lesson: moving the cat!" }); + const tags = String(r.rows[0].tags || ""); + if (tags.includes("completed")) { + return mcpOk({ suggestion: "Great job finishing that! Ready for the next one?" }); + } + return mcpOk({ suggestion: "Let's try that one again — we almost had it!" }); + } catch { + return mcpOk({ suggestion: "Ready to build something cool?" }); + } + } + ); + + server.tool( + "maker_save_artifact", + "Save a learner-produced artifact (e.g. Blockly workspace XML, drawing). Guest sessions return a friendly 'cannot save in guest mode' message. Real file storage lands in Phase 2.", + { + session_token: z.string().min(1), + title: z.string().min(1).max(200), + mime: z.string().max(100).default("application/octet-stream").optional(), + blob_b64: z.string().max(1_500_000).describe("Base64-encoded artifact, max ~1MB"), + }, + async ({ session_token, title, mime = "application/octet-stream", blob_b64 }) => { + const sess = await resolveSession(db, session_token); + if (!sess) return mcpError("Session invalid or expired"); + if (sess.is_guest) { + return mcpOk({ saved: false, message: "Your work won't be saved in guest mode. Ask a grown-up to set up a profile to keep your creations!" }); + } + // Phase 1: stub — record reference only, real storage upload lands in Phase 2. + try { + await db.execute({ + sql: `INSERT INTO memories (content, context, category, importance, tags, project_id, source, created_at) + VALUES (?, ?, 'learning', 6, 'maker-lab,artifact', ?, 'maker-lab', datetime('now'))`, + args: [ + `Saved ${mime}, ${blob_b64.length} bytes (base64) — Phase 2 will upload to crow-storage.`, + `Artifact: ${title}`, + sess.learner_id, + ], + }); + return mcpOk({ saved: true, title, note: "Phase 1 stub — real storage upload in Phase 2." }); + } catch (err) { + return mcpError(`Failed to record artifact: ${err.message}`); + } + } + ); + + // ─── Admin: data handling (COPPA / GDPR-K) ────────────────────────────── + + server.tool( + "maker_export_learner", + "Export all data for a learner as a JSON bundle (for parental-request responses and right-to-be-forgotten preparation). Admin-only.", + { learner_id: z.number().int().positive() }, + async ({ learner_id }) => { + const [profile, settings, sessions, transcripts, memories] = await Promise.all([ + db.execute({ sql: `SELECT * FROM research_projects WHERE id=? AND type='learner_profile'`, args: [learner_id] }), + db.execute({ sql: `SELECT * FROM maker_learner_settings WHERE learner_id=?`, args: [learner_id] }), + db.execute({ sql: `SELECT token, started_at, expires_at, revoked_at, state, hints_used, batch_id FROM maker_sessions WHERE learner_id=?`, args: [learner_id] }), + db.execute({ sql: `SELECT * FROM maker_transcripts WHERE learner_id=? ORDER BY created_at`, args: [learner_id] }), + db.execute({ sql: `SELECT context AS title, content, tags, category, importance, created_at FROM memories WHERE project_id=? AND source='maker-lab' ORDER BY created_at`, args: [learner_id] }).catch(() => ({ rows: [] })), + ]); + if (!profile.rows.length) return mcpError(`Learner ${learner_id} not found`); + return mcpOk({ + export_version: 1, + exported_at: new Date().toISOString(), + profile: profile.rows[0], + settings: settings.rows[0] || null, + sessions: sessions.rows, + transcripts: transcripts.rows, + memories: memories.rows, + }); + } + ); + + // ─── Lesson authoring ─────────────────────────────────────────────────── + + server.tool( + "maker_validate_lesson", + "Validate a lesson JSON against the schema. Returns specific errors so custom lesson authors (teachers/parents) can fix them without reading code.", + { lesson: z.record(z.any()) }, + async ({ lesson }) => { + const errs = []; + const required = ["id", "title", "surface", "age_band", "steps", "canned_hints"]; + for (const k of required) { + if (!(k in lesson)) errs.push(`missing: ${k}`); + } + if (lesson.age_band && !["5-9", "10-13", "14+"].includes(lesson.age_band)) { + errs.push(`age_band must be one of 5-9 | 10-13 | 14+`); + } + if (lesson.canned_hints && !Array.isArray(lesson.canned_hints)) { + errs.push("canned_hints must be an array of strings"); + } + if (Array.isArray(lesson.canned_hints)) { + for (let i = 0; i < lesson.canned_hints.length; i++) { + if (typeof lesson.canned_hints[i] !== "string") { + errs.push(`canned_hints[${i}] must be a string`); + } + } + } + if (lesson.reading_level != null && (typeof lesson.reading_level !== "number" || lesson.reading_level > 3)) { + errs.push("reading_level must be a number ≤ 3 for the 5-9 band"); + } + if (Array.isArray(lesson.steps)) { + for (let i = 0; i < lesson.steps.length; i++) { + const s = lesson.steps[i]; + if (!s || typeof s !== "object") { errs.push(`steps[${i}] must be an object`); continue; } + if (!s.prompt) errs.push(`steps[${i}].prompt missing`); + } + } + return mcpOk({ valid: errs.length === 0, errors: errs }); + } + ); + + return server; +} diff --git a/bundles/maker-lab/skills/maker-lab.md b/bundles/maker-lab/skills/maker-lab.md new file mode 100644 index 0000000..48c86ca --- /dev/null +++ b/bundles/maker-lab/skills/maker-lab.md @@ -0,0 +1,97 @@ +--- +name: maker-lab +description: Scaffolded AI learning companion for kids and self-learners. Hint-ladder pedagogy, age-banded personas, per-learner memory. Solo / family / classroom modes with a guest sidecar. +triggers: + - "help my kid learn" + - "teach Ada coding" + - "start a STEM session" + - "set up maker lab" + - "start tutor session" + - "try maker lab" + - "classroom session" +tools: + - maker_create_learner + - maker_list_learners + - maker_start_session + - maker_start_sessions_bulk + - maker_start_guest_session + - maker_end_session + - maker_hint + - maker_log_progress + - maker_next_suggestion + - maker_save_artifact + - maker_export_learner + - maker_delete_learner + - maker_set_mode + - maker_validate_lesson +--- + +# Maker Lab — Behavioral Skill + +Maker Lab pairs a scaffolded AI learning companion with FOSS maker surfaces (Blockly first). It targets **ages 5–9 first** with a **hint-ladder** pedagogy and extends cleanly to older kids and self-learning adults via age-banded personas. + +## Core rules + +1. **Never take `learner_id` as an argument.** Every tool takes `session_token` and resolves server-side. If an LLM hallucinates a different learner, the resolver ignores it. +2. **Admin actions only from the Crow's Nest panel.** `maker_create_learner`, `maker_delete_learner`, `maker_start_session`, `maker_set_mode`, `maker_revoke_batch`, `maker_force_end_session` are admin-only — the kid's LLM never calls them. +3. **Never initiate peer-sharing during a kid session.** No `crow_share`, `crow_send_message`, `crow_generate_invite`. These are disabled at the bundle level while a session is active; even suggesting them in chat is wrong. +4. **Consent is captured at learner creation.** Don't skip it when a parent asks to "just make a profile real quick" — the checkbox matters legally (COPPA / GDPR-K) and is stored with a timestamp. +5. **Tier 1 confirm on delete.** `maker_delete_learner` cascades across sessions, transcripts, memories, and storage references. Always do the two-step confirm before calling. + +## Personas (resolved server-side from age) + +| Age band | Persona | Reading budget | Hint ladder | +|---|---|---|---| +| 5–9 | `kid-tutor` | 1st–3rd grade, ≤ 40 words/hint | strict: nudge → partial → demonstrate | +| 10–13 | `tween-tutor` | middle grade, ≤ 80 words | scaffolded, accepts direct questions | +| 14+ | `adult-tutor` | plain technical, ≤ 200 words | direct Q&A; no hint ladder required | + +Persona is resolved from the session's learner age (or guest age-band). The LLM cannot choose its own persona. + +## The hint ladder (kid-tutor) + +When a 5–9-year-old asks for help: + +1. **Nudge** — guiding question. "What do you think happens if the cat block is inside the repeat?" +2. **Partial** — point to the specific spot. "Look at the block right above the move. What do you notice?" +3. **Demonstrate** — plain-language explanation with the answer. + +Escalate one level per repeated ask. Log each interaction as a `learning` memory scoped to the learner. + +Tween-tutor relaxes the ladder (accept direct questions). Adult-tutor drops it (direct Q&A). + +## Safety — every hint passes the server-side filter + +Every response from `maker_hint` is filtered before it reaches the companion's TTS: + +- **Reading grade** (kid-tutor only): refuse and fall back to a canned hint if grade > 3. +- **Length cap**: 40 / 80 / 200 words by band. +- **Blocklist**: a small set of scary/adult terms. On match, fall back to a canned hint. +- **Rate limit**: 6 hints/min per session. Exceeding it returns "Let's think for a minute before asking again!" +- **Failure fallback**: canned lesson hint or persona-appropriate generic hint. The kid never sees a raw error. + +Do not try to route around the filter. It exists because prompt-only safety is not adequate for 5-year-olds. + +## Modes + +- **Solo**: one implicit default learner, auto-mint + auto-redeem. Kiosk binds to 127.0.0.1 by default; LAN exposure requires per-device binding via Crow's Nest login. +- **Family**: admin creates learners, starts sessions, hands off redemption codes. +- **Classroom**: grid view, bulk session start, printable QR sheet. Hardware floor: 16 GB RAM recommended; panel warns on sub-16 GB hosts. +- **Guest sidecar**: ephemeral session, age-picker drives persona, no saves, no memories, 30-min cap. + +## Session handoff + +Never put raw session tokens in URLs. `maker_start_session` returns a **redemption code** (e.g., `ABC-123`) that the admin prints / shows via QR. The kiosk visits `/kiosk/r/` and receives an HttpOnly cookie bound to the device fingerprint. + +To revoke a lost QR sheet: `maker_revoke_batch(batch_id, reason)`. No forensic DB edits. + +## Writing custom lessons + +Teachers/parents add lessons without touching code: + +- JSON schema: `bundles/maker-lab/curriculum/SCHEMA.md` +- Validator: `maker_validate_lesson(lesson)` +- Panel "Import lesson" drops the file into `~/.crow/bundles/maker-lab/curriculum/custom/` +- No restart needed + +Lesson required fields: `id`, `title`, `surface`, `age_band` (`5-9|10-13|14+`), `steps[]`, `canned_hints[]`. Use the `reading_level` field to self-declare grade level; the bundle validates it. diff --git a/registry/add-ons.json b/registry/add-ons.json index 011f2d5..fb7e806 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -1,6 +1,31 @@ { "version": 2, "add-ons": [ + { + "id": "maker-lab", + "name": "Maker Lab", + "version": "0.1.0", + "description": "Scaffolded AI learning companion paired with FOSS maker surfaces (Blockly first). Hint-ladder pedagogy, per-learner memory, age-banded personas, classroom-capable.", + "type": "mcp-server", + "author": "Crow", + "category": "education", + "tags": ["education", "stem", "kids", "classroom", "tutor", "blockly", "maker"], + "icon": "graduation-cap", + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MAKER_LAB_MODE", "MAKER_LAB_LLM_ENDPOINT", "MAKER_LAB_LLM_MODEL"] + }, + "panel": "panel/maker-lab.js", + "skills": ["skills/maker-lab.md"], + "requires": { + "min_ram_mb": 256, + "min_disk_mb": 100, + "bundles": ["companion"] + }, + "env_vars": [], + "notes": "Hard deps: companion bundle + any OpenAI-compatible local-LLM endpoint. Recommended engine: ollama (solo/family) or vllm (classroom). See bundles/maker-lab/PHASE-0-REPORT.md." + }, { "id": "obsidian", "name": "Obsidian Vault", diff --git a/servers/gateway/dashboard/nav-registry.js b/servers/gateway/dashboard/nav-registry.js index 1fa8542..15bc77e 100644 --- a/servers/gateway/dashboard/nav-registry.js +++ b/servers/gateway/dashboard/nav-registry.js @@ -41,6 +41,7 @@ const CATEGORY_TO_GROUP = { finance: "tools", infrastructure: "tools", automation: "tools", + education: "content", system: "system", }; diff --git a/servers/gateway/dashboard/panels/extensions.js b/servers/gateway/dashboard/panels/extensions.js index ee66b44..d3665ed 100644 --- a/servers/gateway/dashboard/panels/extensions.js +++ b/servers/gateway/dashboard/panels/extensions.js @@ -51,6 +51,7 @@ const ICON_MAP = { shield: "\u{1F6E1}\uFE0F", activity: "\u{1F4C8}", eye: "\u{1F441}\uFE0F", + "graduation-cap": "\u{1F393}", }; const CATEGORY_COLORS = { @@ -66,6 +67,7 @@ const CATEGORY_COLORS = { finance: { bg: "rgba(245,158,11,0.12)", color: "#f59e0b" }, infrastructure: { bg: "rgba(148,163,184,0.12)", color: "#94a3b8" }, automation: { bg: "rgba(45,212,191,0.12)", color: "#2dd4bf" }, + education: { bg: "rgba(132,204,22,0.12)", color: "#84cc16" }, other: { bg: "rgba(161,161,170,0.12)", color: "#a1a1aa" }, }; @@ -82,6 +84,7 @@ const CATEGORY_LABELS = { finance: "extensions.categoryFinance", infrastructure: "extensions.categoryInfrastructure", automation: "extensions.categoryAutomation", + education: "extensions.categoryEducation", other: "extensions.categoryOther", }; diff --git a/servers/gateway/dashboard/panels/nest/data-queries.js b/servers/gateway/dashboard/panels/nest/data-queries.js index c20e224..ed05b6d 100644 --- a/servers/gateway/dashboard/panels/nest/data-queries.js +++ b/servers/gateway/dashboard/panels/nest/data-queries.js @@ -104,7 +104,7 @@ export async function getNestData(db, lang) { const [memR, srcR, projR, conR, blogR, pageCntR, pageSzR] = await Promise.all([ db.execute("SELECT COUNT(*) as c FROM memories"), db.execute("SELECT COUNT(*) as c FROM research_sources"), - db.execute("SELECT COUNT(*) as c FROM research_projects"), + db.execute("SELECT COUNT(*) as c FROM research_projects WHERE (type IS NULL OR type != 'learner_profile')"), db.execute("SELECT COUNT(*) as c FROM contacts"), db.execute("SELECT COUNT(*) as c FROM blog_posts"), db.execute("PRAGMA page_count"), diff --git a/servers/gateway/dashboard/panels/projects.js b/servers/gateway/dashboard/panels/projects.js index bc59e01..7705019 100644 --- a/servers/gateway/dashboard/panels/projects.js +++ b/servers/gateway/dashboard/panels/projects.js @@ -85,21 +85,24 @@ async function renderListView(db, query, layout, lang) { const statusFilter = query.status || null; const searchQuery = query.q || null; - // Count and fetch projects - let countSql = "SELECT COUNT(*) as c FROM research_projects"; + // Count and fetch projects. + // Exclude learner_profile rows — those are maker-lab learner profiles + // and belong on the Maker Lab panel, not here. + let countSql = "SELECT COUNT(*) as c FROM research_projects WHERE (type IS NULL OR type != 'learner_profile')"; let fetchSql = ` SELECT p.*, (SELECT COUNT(*) FROM research_sources WHERE project_id = p.id) as source_count, (SELECT COUNT(*) FROM research_notes WHERE project_id = p.id) as note_count, (SELECT COUNT(*) FROM data_backends WHERE project_id = p.id) as backend_count FROM research_projects p + WHERE (p.type IS NULL OR p.type != 'learner_profile') `; const countArgs = []; const fetchArgs = []; if (statusFilter) { - countSql += " WHERE status = ?"; - fetchSql += " WHERE p.status = ?"; + countSql += " AND status = ?"; + fetchSql += " AND p.status = ?"; countArgs.push(statusFilter); fetchArgs.push(statusFilter); } @@ -107,9 +110,8 @@ async function renderListView(db, query, layout, lang) { if (searchQuery) { const safe = sanitizeFtsQuery(searchQuery); if (safe) { - const whereKeyword = statusFilter ? " AND" : " WHERE"; - countSql += `${whereKeyword} name LIKE ? ESCAPE '\\'`; - fetchSql += `${whereKeyword} p.name LIKE ? ESCAPE '\\'`; + countSql += ` AND name LIKE ? ESCAPE '\\'`; + fetchSql += ` AND p.name LIKE ? ESCAPE '\\'`; const pattern = `%${escapeLikePattern(searchQuery)}%`; countArgs.push(pattern); fetchArgs.push(pattern); @@ -229,7 +231,7 @@ async function renderListView(db, query, layout, lang) { async function renderDetailView(db, projectId, layout, lang) { const { rows: projRows } = await db.execute({ - sql: "SELECT * FROM research_projects WHERE id = ?", + sql: "SELECT * FROM research_projects WHERE id = ? AND (type IS NULL OR type != 'learner_profile')", args: [projectId], }); diff --git a/servers/gateway/dashboard/shared/i18n.js b/servers/gateway/dashboard/shared/i18n.js index 69f1dd6..f1387bb 100644 --- a/servers/gateway/dashboard/shared/i18n.js +++ b/servers/gateway/dashboard/shared/i18n.js @@ -412,6 +412,7 @@ const translations = { "extensions.categoryFinance": { en: "Finance", es: "Finanzas" }, "extensions.categoryInfrastructure": { en: "Infrastructure", es: "Infraestructura" }, "extensions.categoryAutomation": { en: "Automation", es: "Automatizaci\u00f3n" }, + "extensions.categoryEducation": { en: "Education", es: "Educaci\u00f3n" }, "extensions.categoryOther": { en: "Other", es: "Otros" }, // ─── Skills Panel ─── diff --git a/servers/memory/crow-context.js b/servers/memory/crow-context.js index 28ca61d..b0487f0 100644 --- a/servers/memory/crow-context.js +++ b/servers/memory/crow-context.js @@ -114,7 +114,7 @@ async function generateDynamicSections(db) { // Active research projects const { rows: projects } = await db.execute( - "SELECT name, description, (SELECT COUNT(*) FROM research_sources WHERE project_id = research_projects.id) as source_count, (SELECT COUNT(*) FROM research_notes WHERE project_id = research_projects.id) as note_count FROM research_projects WHERE status = 'active' ORDER BY updated_at DESC LIMIT 5" + "SELECT name, description, (SELECT COUNT(*) FROM research_sources WHERE project_id = research_projects.id) as source_count, (SELECT COUNT(*) FROM research_notes WHERE project_id = research_projects.id) as note_count FROM research_projects WHERE status = 'active' AND (type IS NULL OR type != 'learner_profile') ORDER BY updated_at DESC LIMIT 5" ); if (projects.length > 0) { lines.push("### Active Research Projects"); diff --git a/servers/memory/server.js b/servers/memory/server.js index a022689..0686711 100644 --- a/servers/memory/server.js +++ b/servers/memory/server.js @@ -213,14 +213,15 @@ export function createMemoryServer(dbPath, options = {}) { server.tool( "crow_recall_by_context", - "Retrieve memories relevant to a given context. Uses full-text search across content, context, and tags to find the most relevant stored information.", + "Retrieve memories relevant to a given context. Uses full-text search across content, context, and tags. Memories tagged source='maker-lab' are excluded by default to keep kid-session memories out of generic recall; pass include_maker_lab=true to include them (use this when operating within the maker-lab skill).", { context: z.string().max(2000).describe("Describe the current context or topic to find relevant memories"), limit: z.number().max(100).default(5).describe("Maximum results"), instance_id: z.string().max(100).optional().describe("Filter by origin instance ID"), project_id: z.number().optional().describe("Filter by project ID"), + include_maker_lab: z.boolean().optional().describe("When true, include memories tagged source='maker-lab'. Default false."), }, - async ({ context, limit, instance_id, project_id }) => { + async ({ context, limit, instance_id, project_id, include_maker_lab }) => { const contextWords = context.split(/\s+/).filter((w) => w.length > 2).slice(0, 10).join(" "); const safeQuery = sanitizeFtsQuery(contextWords); @@ -237,6 +238,9 @@ export function createMemoryServer(dbPath, options = {}) { if (instance_id) { sql += " AND m.instance_id = ?"; params.push(instance_id); } if (project_id) { sql += " AND m.project_id = ?"; params.push(project_id); } + if (!include_maker_lab) { + sql += " AND (m.source IS NULL OR m.source != 'maker-lab')"; + } sql += " ORDER BY rank LIMIT ?"; params.push(limit); diff --git a/servers/research/server.js b/servers/research/server.js index fbd5026..7e8a9f8 100644 --- a/servers/research/server.js +++ b/servers/research/server.js @@ -185,6 +185,10 @@ export function createProjectServer(dbPath, options = {}) { if (type) { conditions.push("p.type = ?"); params.push(type); + } else { + // Hide learner profiles (maker-lab) from the generic project listing + // unless the caller explicitly filters by type. + conditions.push("(p.type IS NULL OR p.type != 'learner_profile')"); } if (conditions.length > 0) { sql += " WHERE " + conditions.join(" AND "); @@ -554,7 +558,7 @@ export function createProjectServer(dbPath, options = {}) { "Get statistics about the project database.", {}, async () => { - const projects = (await db.execute("SELECT COUNT(*) as count FROM research_projects")).rows[0]; + const projects = (await db.execute("SELECT COUNT(*) as count FROM research_projects WHERE (type IS NULL OR type != 'learner_profile')")).rows[0]; const sources = (await db.execute("SELECT COUNT(*) as count FROM research_sources")).rows[0]; const verified = (await db.execute("SELECT COUNT(*) as count FROM research_sources WHERE verified = 1")).rows[0]; const byType = (await db.execute("SELECT source_type, COUNT(*) as count FROM research_sources GROUP BY source_type ORDER BY count DESC")).rows; @@ -757,7 +761,7 @@ export function createProjectServer(dbPath, options = {}) { server.resource("projects", "projects://list", async (uri) => { const { rows: projects } = await db.execute( - "SELECT id, name, type, status, description FROM research_projects ORDER BY updated_at DESC" + "SELECT id, name, type, status, description FROM research_projects WHERE (type IS NULL OR type != 'learner_profile') ORDER BY updated_at DESC" ); return { contents: [ diff --git a/skills/superpowers.md b/skills/superpowers.md index ec2fb70..d81a779 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -68,6 +68,7 @@ This is the master routing skill. Consult this **before every task** to determin | "what can you do", "getting started", "new to crow" | "qué puedes hacer", "cómo empezar", "nuevo en crow" | onboarding-tour | crow-memory | | "tailscale", "remote access", "network setup", "private access", "VPN" | "tailscale", "acceso remoto", "configurar red", "acceso privado", "red privada" | network-setup, tailscale | (documentation) | | "report bug", "file issue", "feature request", "found a bug" | "reportar bug", "crear issue", "solicitar función" | bug-report | crow-memory, github | +| "help my kid learn", "teach Ada coding", "start a STEM session", "maker lab", "classroom session", "tutor session", "try it without saving" | "ayuda a mi hijo aprender", "enseñar programación a Ada", "sesión STEM", "laboratorio creativo", "modo aula" | maker-lab | maker-lab, companion, crow-memory | | "obsidian", "vault", "daily note", "sync to obsidian" | "obsidian", "bóveda", "nota diaria" | obsidian | obsidian, crow-projects | | "lights", "temperature", "smart home", "turn on/off" | "luces", "temperatura", "hogar inteligente" | home-assistant | home-assistant | | "ollama", "local model", "run locally", "embeddings" | "ollama", "modelo local", "ejecutar local" | ollama | crow-memory | From d01f05754bec11119949c17c25cc2aa4eea2ec91 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 12:44:32 -0500 Subject: [PATCH 3/9] Maker Lab: Phase 2 kiosk routes, Blockly surface, peer-sharing guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 wires the first maker surface and its security-critical handoff. Kiosk HTTP routes (bundles/maker-lab/panel/routes.js): - GET /kiosk/r/:code — atomic redemption. Uses UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING so an expired or already-used code fails in the same WHERE clause. No TOCTOU. Issues an HttpOnly, SameSite=Strict, __Host--prefixed cookie signed with HMAC-SHA256; payload = sessionToken.fingerprint. - Fingerprint = sha256(UA + Accept-Language + per-device localStorage salt echoed via x-maker-kiosk-salt). Every subsequent /kiosk/* hit re-verifies signature AND fingerprint — lifting a cookie to a different browser fails. - Cookie secret persists at ~/.crow/maker-lab.cookie.secret. Rotating the secret invalidates all kiosks (force re-bind). - /kiosk/api/context, /api/lesson/:id, /api/progress, /api/hint, /api/end — all session-cookie-guarded, no Nest password required, state machine enforced (ending/revoked responses). Blockly kiosk (bundles/maker-lab/public/blockly/): - Minimal index.html + kiosk.css + tutor-bridge.js. - tutor-bridge.js: session-cookie API client, "?" hint button with level escalation, "I'm done!" progress POST, IndexedDB offline queue with online-replay, browser speechSynthesis TTS for the Phase 2 MVP audio path, kid-visible transcript indicator from /api/context. - Blockly loaded from pinned jsDelivr (self-host for air-gap, noted in SCHEMA.md). Curriculum (bundles/maker-lab/curriculum/): - SCHEMA.md documents the lesson JSON shape for teacher/parent authors. - Three starter 5-9 lessons: move-cat, repeat, on-click. Companion patch drafts (bundles/companion/patches/backend/): - 0001-tutor-event-handler.patch: typed WS message for scaffolded hints. The handler NEVER treats the payload as user text — only the filtered return from maker_hint reaches TTS. - 0003-maker-lab-mcp-registration.patch: optional direct MCP bridge (tools already reachable via the existing crow router bridge). - README documents that these apply via the Phase 3 submodule build pipeline; Phase 2 MVP runs via HTTP + browser TTS without them. - 0002 slot empty (Spike 1 — backend already per-connection). Peer-sharing guard (servers/shared/kiosk-guard.js + servers/sharing/server.js): crow_generate_invite, crow_share, and crow_send_message refuse to run while any maker_sessions row is active — defense-in-depth for the rule "no peer-sharing ever initiated from inside a kid session." Cached 1s to avoid per-call DB churn; silent no-op on installs without the maker_sessions table. --- .../backend/0001-tutor-event-handler.patch | 77 ++++ .../0003-maker-lab-mcp-registration.patch | 40 ++ bundles/companion/patches/backend/README.md | 21 + bundles/maker-lab/curriculum/SCHEMA.md | 62 +++ .../age-5-9/blockly-01-move-cat.json | 18 + .../curriculum/age-5-9/blockly-02-repeat.json | 20 + .../age-5-9/blockly-03-on-click.json | 19 + bundles/maker-lab/manifest.json | 1 + bundles/maker-lab/panel/routes.js | 417 ++++++++++++++++++ bundles/maker-lab/public/blockly/index.html | 51 +++ bundles/maker-lab/public/blockly/kiosk.css | 54 +++ .../maker-lab/public/blockly/tutor-bridge.js | 239 ++++++++++ registry/add-ons.json | 1 + servers/shared/kiosk-guard.js | 48 ++ servers/sharing/server.js | 4 + 15 files changed, 1072 insertions(+) create mode 100644 bundles/companion/patches/backend/0001-tutor-event-handler.patch create mode 100644 bundles/companion/patches/backend/0003-maker-lab-mcp-registration.patch create mode 100644 bundles/companion/patches/backend/README.md create mode 100644 bundles/maker-lab/curriculum/SCHEMA.md create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json create mode 100644 bundles/maker-lab/panel/routes.js create mode 100644 bundles/maker-lab/public/blockly/index.html create mode 100644 bundles/maker-lab/public/blockly/kiosk.css create mode 100644 bundles/maker-lab/public/blockly/tutor-bridge.js create mode 100644 servers/shared/kiosk-guard.js diff --git a/bundles/companion/patches/backend/0001-tutor-event-handler.patch b/bundles/companion/patches/backend/0001-tutor-event-handler.patch new file mode 100644 index 0000000..65d6edc --- /dev/null +++ b/bundles/companion/patches/backend/0001-tutor-event-handler.patch @@ -0,0 +1,77 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH 0001] maker-lab: tutor-event WebSocket message handler + +Adds a typed `tutor-event` WebSocket message and dispatcher. Used by the +Maker Lab Blockly kiosk's tutor-bridge.js to request scaffolded hints that +the companion then speaks via the per-client TTS engine. + +This is NOT a text-input prefix hack — tutor-event payloads are never +spoken verbatim. The handler validates session_token against maker-lab, +calls maker_hint (server-side filtered + rate-limited), and only the +tool's return value is rendered. + +Contract: + { type: "tutor-event", + event: "hint_request", + session_token: str, # validated against maker-lab + surface: str, + question: str, + level: int, # 1..3 + lesson_id: str | None, + canned_hints: [str] | None } + +Applies against upstream commit: +--- + src/open_llm_vtuber/websocket_handler.py | 33 ++++++++++++++++++++++++ + 1 file changed, 33 insertions(+) + +diff --git a/src/open_llm_vtuber/websocket_handler.py b/src/open_llm_vtuber/websocket_handler.py +--- a/src/open_llm_vtuber/websocket_handler.py ++++ b/src/open_llm_vtuber/websocket_handler.py +@@ -84,6 +84,7 @@ class WebSocketHandler: + "fetch-configs": self._handle_fetch_configs, + "switch-config": self._handle_config_switch, + "fetch-backgrounds": self._handle_fetch_backgrounds, ++ "tutor-event": self._handle_tutor_event, + "audio-play-start": self._handle_audio_play_start, + "request-init-config": self._handle_init_config_request, + "heartbeat": self._handle_heartbeat, +@@ -650,3 +651,34 @@ class WebSocketHandler: + if config_file_name: + context = self.client_contexts[client_uid] + await context.handle_config_switch(websocket, config_file_name) ++ ++ async def _handle_tutor_event( ++ self, websocket: WebSocket, client_uid: str, data: dict ++ ): ++ """Maker Lab tutor event (hint_request etc). ++ ++ Validates session_token against maker-lab via the already-bridged ++ `crow` MCP proxy. Never treats the payload as user text; the LLM ++ path stays unmodified. Only the filtered return is rendered. ++ """ ++ import aiohttp ++ event = str(data.get("event") or "") ++ token = str(data.get("session_token") or "") ++ if not token or event != "hint_request": ++ return ++ url = f"http://localhost:3004/maker-lab/api/hint-internal" ++ payload = { ++ "session_token": token, ++ "surface": str(data.get("surface") or ""), ++ "question": str(data.get("question") or ""), ++ "level": int(data.get("level") or 1), ++ "lesson_id": data.get("lesson_id"), ++ "canned_hints": data.get("canned_hints"), ++ } ++ try: ++ async with aiohttp.ClientSession() as s: ++ async with s.post(url, json=payload, timeout=20) as resp: ++ reply = await resp.json() ++ except Exception as err: ++ reply = {"text": "Your tutor is taking a nap.", "source": f"http_error:{err}"} ++ context = self.client_contexts.get(client_uid) ++ if context and context.tts_engine: ++ await context.tts_engine.speak(reply.get("text") or "") +-- +2.40.0 diff --git a/bundles/companion/patches/backend/0003-maker-lab-mcp-registration.patch b/bundles/companion/patches/backend/0003-maker-lab-mcp-registration.patch new file mode 100644 index 0000000..2f0dce6 --- /dev/null +++ b/bundles/companion/patches/backend/0003-maker-lab-mcp-registration.patch @@ -0,0 +1,40 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH 0003] maker-lab: register as fourth MCP server + +Adds maker-lab to the companion's generated mcp_servers.json so the +companion's LLM can discover maker_* tools by name (not just via the +router category). + +Optional for Phase 2 MVP — tools are already reachable via the existing +`crow` router bridge. Direct registration improves LLM tool-selection +accuracy on adult-tutor sessions where the LLM might pick its own tools. + +Note: unlike 0001, this patch is against BUNDLE code +(bundles/companion/scripts/generate-config.py), not upstream. It's filed +in patches/backend/ because the Phase 3 submodule flow applies all +backend-side changes together. + +--- + scripts/generate-config.py | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/scripts/generate-config.py b/scripts/generate-config.py +--- a/scripts/generate-config.py ++++ b/scripts/generate-config.py +@@ -568,6 +568,14 @@ def main(): + "--transport", "streamablehttp", + ], + }, ++ "maker-lab": { ++ "command": "uv", ++ "args": [ ++ "run", "mcp-proxy", ++ f"http://{mcp_bridge_host}:{mcp_bridge_port}/maker-lab/mcp", ++ "--transport", "streamablehttp", ++ ], ++ } if os.environ.get("MAKER_LAB_ENABLED") == "1" else None, + } + } + # Strip None-valued entries (conditional servers not enabled) +-- +2.40.0 diff --git a/bundles/companion/patches/backend/README.md b/bundles/companion/patches/backend/README.md new file mode 100644 index 0000000..5389e39 --- /dev/null +++ b/bundles/companion/patches/backend/README.md @@ -0,0 +1,21 @@ +# Companion Backend Patches (Open-LLM-VTuber Python) + +Patches against the upstream Python backend (`github.com/Open-LLM-VTuber/Open-LLM-VTuber`). + +Currently in **draft form** — these document the intended changes. Applied automatically once Phase 3's submodule + build pipeline lands (`bundles/companion/scripts/build-pet-linux.sh`). + +For Phase 2 MVP, the kiosk flow runs end-to-end via `/kiosk/api/hint` (HTTP) + browser TTS (speechSynthesis). The companion WS integration (`tutor-event` message + persona swap via `switch-config`) is a quality upgrade that depends on these patches. + +## Patch slots + +| Patch | Status | What it does | +|---|---|---| +| `0001-tutor-event-handler.patch` | draft | Adds `"tutor-event"` to `_message_handlers` in `websocket_handler.py`; the handler validates `session_token` against maker-lab, calls `maker_hint`, and speaks the filtered response via the per-client `ServiceContext.tts_engine`. | +| `0002-per-connection-session-context.patch` | **not needed** (Spike 1) | Backend is already per-connection (`client_contexts: Dict[str, ServiceContext]` with `model_copy(deep=True)` per session). Slot stays empty. | +| `0003-maker-lab-mcp-registration.patch` | draft | Adds `maker-lab` as a fourth MCP server in `generate-config.py`'s generated `mcp_servers.json`. For Phase 2 MVP, tools are already reachable via the existing `crow` router bridge, so this is optional. | + +## Persona swap — no patch needed + +Spike 2 confirmed the upstream `switch-config` WebSocket message already provides per-connection persona swap via character YAMLs in `/app/characters/`. Maker-lab ships three YAMLs (`maker_lab_kid_tutor.yaml`, `maker_lab_tween_tutor.yaml`, `maker_lab_adult_tutor.yaml`) and drives the swap server-side on `maker_start_session`. + +The YAML generator and the server-side `switch-config` origination are Phase 2.1 work (not required for MVP since browser TTS currently carries the hint audio). diff --git a/bundles/maker-lab/curriculum/SCHEMA.md b/bundles/maker-lab/curriculum/SCHEMA.md new file mode 100644 index 0000000..7bf02bd --- /dev/null +++ b/bundles/maker-lab/curriculum/SCHEMA.md @@ -0,0 +1,62 @@ +# Maker Lab — Lesson JSON Schema + +Lessons live as JSON files under `bundles/maker-lab/curriculum/age-/`. + +Teachers and parents can add custom lessons without touching code. Place the +file at `~/.crow/bundles/maker-lab/curriculum/custom/.json`. The kiosk +will pick them up at request time (no restart). + +Validate a lesson via the MCP tool `maker_validate_lesson`. It returns specific +errors like `missing: canned_hints[]` or `reading_level must be a number <= 3`. + +## Required fields + +| Field | Type | Notes | +|---|---|---| +| `id` | string | Stable unique id. Used in URLs and progress logs. Alphanumeric + dash. | +| `title` | string | Short human title, spoken to the kid. | +| `surface` | string | Which maker surface: `"blockly"`, `"scratch"`, `"kolibri"`. | +| `age_band` | enum | One of `"5-9"`, `"10-13"`, `"14+"`. | +| `steps` | array | One or more `{ prompt, solution_hint? }` objects. | +| `canned_hints` | array of strings | Fallback hints when the LLM is unavailable or filtered. At least one required. | + +## Optional fields + +| Field | Type | Notes | +|---|---|---| +| `goal` | string | Short description for the tutor's system prompt. | +| `reading_level` | number | Self-declared grade. For `5-9`, must be `<= 3`. | +| `starter_workspace` | string | Blockly XML to prefill the workspace. | +| `success_check` | object | Lightweight pattern-match against submitted workspace XML. | +| `background` | string | Lesson cover image filename in the bundle's assets dir. | +| `tags` | array of strings | For organization. | + +## Example + +```json +{ + "id": "blockly-01-move-cat", + "title": "Move the Cat", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Drag a move block and run it to move the cat across the screen.", + "steps": [ + { "prompt": "Drag the 'move' block into the workspace." }, + { "prompt": "Click the green play button!" } + ], + "canned_hints": [ + "Look for the block shaped like a little arrow!", + "Try dragging it right under the 'when start' block.", + "The green play button is at the top!" + ], + "tags": ["sequences", "starter"] +} +``` + +## Authoring tips + +- Keep `canned_hints` short and warm. They're the safety net when everything else fails. +- `steps[].prompt` is **not** spoken verbatim. The tutor paraphrases it through the persona filter. +- Prefer **questions** over directives for the 5-9 band: "What do you think this block does?" beats "Drag this block." +- If a lesson needs words the blocklist rejects ("hell", "damn", "kill", etc. in anything other than a programming sense), find softer synonyms. The filter doesn't check context. diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json b/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json new file mode 100644 index 0000000..132c043 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json @@ -0,0 +1,18 @@ +{ + "id": "blockly-01-move-cat", + "title": "Move the Cat", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Drag a move block into the workspace and click play to move the cat.", + "steps": [ + { "prompt": "Drag the move block into the workspace." }, + { "prompt": "Click the green play button!" } + ], + "canned_hints": [ + "Look for the block shaped like a little arrow!", + "Try dragging it right under the 'when start' block.", + "The green play button is at the top!" + ], + "tags": ["sequences", "starter"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json b/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json new file mode 100644 index 0000000..764ee75 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json @@ -0,0 +1,20 @@ +{ + "id": "blockly-02-repeat", + "title": "Make the Cat Dance", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Use a repeat block so the cat moves four times.", + "steps": [ + { "prompt": "Find the repeat block (it has a little hole inside)." }, + { "prompt": "Put the move block inside the repeat." }, + { "prompt": "Change the number to 4." }, + { "prompt": "Press play and watch the cat go!" } + ], + "canned_hints": [ + "A repeat block is like saying 'do it again, and again, and again!'", + "You can drop other blocks INSIDE the repeat block.", + "Try changing the number inside the repeat. What changes?" + ], + "tags": ["loops", "repetition"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json b/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json new file mode 100644 index 0000000..fc3063f --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json @@ -0,0 +1,19 @@ +{ + "id": "blockly-03-on-click", + "title": "The Cat Wakes Up", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 3, + "goal": "Make the cat do something only when you click it.", + "steps": [ + { "prompt": "Grab the 'when clicked' block." }, + { "prompt": "Put a move block under it." }, + { "prompt": "Press play, then click the cat!" } + ], + "canned_hints": [ + "Events are like doorbells — the code runs when something rings them!", + "The cat won't move until you click on it. That's on purpose.", + "Try putting more than one move block under 'when clicked'." + ], + "tags": ["events", "interaction"] +} diff --git a/bundles/maker-lab/manifest.json b/bundles/maker-lab/manifest.json index 2c4bad2..48bca51 100644 --- a/bundles/maker-lab/manifest.json +++ b/bundles/maker-lab/manifest.json @@ -14,6 +14,7 @@ "envKeys": ["MAKER_LAB_MODE", "MAKER_LAB_LLM_ENDPOINT", "MAKER_LAB_LLM_MODEL"] }, "panel": "panel/maker-lab.js", + "panelRoutes": "panel/routes.js", "skills": ["skills/maker-lab.md"], "requires": { "min_ram_mb": 256, diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js new file mode 100644 index 0000000..07e75ef --- /dev/null +++ b/bundles/maker-lab/panel/routes.js @@ -0,0 +1,417 @@ +/** + * Maker Lab — Kiosk HTTP routes. + * + * These routes bypass dashboardAuth. They use per-session HttpOnly cookies + * issued by /kiosk/r/:code on atomic redemption of a one-shot code. + * + * Security contract (from plan + Spike 0): + * - Redemption is atomic: UPDATE...WHERE used_at IS NULL AND expires_at > now() RETURNING. + * An expired or already-used code fails in the same WHERE clause — no TOCTOU race. + * - Cookie is signed (HMAC-SHA256) and carries the session token + device fingerprint. + * On every /kiosk/* request we re-verify signature + fingerprint; a cookie lifted + * to a different device fails the fingerprint check. + * - Session state machine enforced here: active → ending → revoked. + */ + +import { Router } from "express"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Lazy DB +let createDbClient; +try { + const dbMod = await import(pathToFileURL(resolve(__dirname, "../server/db.js")).href); + createDbClient = dbMod.createDbClient; +} catch { + createDbClient = null; +} + +// Server-side cookie-signing secret. Persisted across restarts via env var; +// if unset we derive one from a per-install file so cookies survive restarts. +// Cookies issued with one secret are invalidated when the secret rotates — +// that's a feature, not a bug (admin can rotate to force all kiosks to re-bind). +function resolveCookieSecret() { + if (process.env.MAKER_LAB_COOKIE_SECRET) return process.env.MAKER_LAB_COOKIE_SECRET; + const home = process.env.HOME || "."; + const path = resolve(home, ".crow", "maker-lab.cookie.secret"); + try { + if (existsSync(path)) return readFileSync(path, "utf8").trim(); + } catch {} + // Fallback: per-process ephemeral. A restart invalidates all cookies. + return randomBytes(32).toString("hex"); +} +const COOKIE_SECRET = resolveCookieSecret(); +const COOKIE_NAME_SECURE = "__Host-maker_sid"; +const COOKIE_NAME_PLAIN = "maker_sid"; +const COOKIE_MAX_AGE_SEC = 6 * 3600; // 6h cap; session exp is authoritative. + +function fingerprint(req) { + const ua = String(req.headers["user-agent"] || "").slice(0, 500); + const al = String(req.headers["accept-language"] || "").slice(0, 200); + // Accept an optional client-side token (set by tutor-bridge.js in localStorage + // and echoed via a custom header). If absent, UA + AL is the floor. + const clientSalt = String(req.headers["x-maker-kiosk-salt"] || "").slice(0, 128); + return createHash("sha256").update(`${ua}\n${al}\n${clientSalt}`).digest("base64url"); +} + +function signCookie(sessionToken, fp) { + const payload = `${sessionToken}.${fp}`; + const sig = createHmac("sha256", COOKIE_SECRET).update(payload).digest("base64url"); + return `${sessionToken}.${fp}.${sig}`; +} + +function verifyCookie(cookie) { + if (!cookie || typeof cookie !== "string") return null; + const parts = cookie.split("."); + if (parts.length !== 3) return null; + const [sessionToken, fp, sig] = parts; + const expected = createHmac("sha256", COOKIE_SECRET).update(`${sessionToken}.${fp}`).digest("base64url"); + try { + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + } catch { + return null; + } + return { sessionToken, fp }; +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const seg of String(header).split(/;\s*/)) { + const idx = seg.indexOf("="); + if (idx < 0) continue; + out[seg.slice(0, idx).trim()] = seg.slice(idx + 1); + } + return out; +} + +function cookieName(req) { + return req.secure ? COOKIE_NAME_SECURE : COOKIE_NAME_PLAIN; +} + +function setSessionCookie(req, res, value) { + const name = cookieName(req); + const flags = [`${name}=${value}`, "Path=/kiosk", "HttpOnly", "SameSite=Strict", `Max-Age=${COOKIE_MAX_AGE_SEC}`]; + if (req.secure) flags.push("Secure"); + res.setHeader("Set-Cookie", flags.join("; ")); +} + +function clearSessionCookie(req, res) { + const name = cookieName(req); + const flags = [`${name}=`, "Path=/kiosk", "HttpOnly", "SameSite=Strict", "Max-Age=0"]; + if (req.secure) flags.push("Secure"); + res.setHeader("Set-Cookie", flags.join("; ")); +} + +async function resolveSessionRow(db, token) { + if (!token) return null; + const r = await db.execute({ + sql: `SELECT s.*, rp.name AS learner_name, rp.metadata AS learner_metadata + FROM maker_sessions s + LEFT JOIN research_projects rp ON rp.id = s.learner_id + WHERE s.token = ?`, + args: [token], + }); + if (!r.rows.length) return null; + const row = r.rows[0]; + if (row.state === "revoked") return null; + if (row.expires_at && row.expires_at < new Date().toISOString()) { + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [token], + }); + return null; + } + return row; +} + +// Extract and verify the current kiosk session from the request. +// Returns { ok: true, session, sessionToken } or { ok: false, reason }. +async function requireKioskSession(req, db) { + const raw = parseCookies(req.headers.cookie)[cookieName(req)]; + const parsed = verifyCookie(raw); + if (!parsed) return { ok: false, reason: "no_cookie" }; + if (parsed.fp !== fingerprint(req)) return { ok: false, reason: "fingerprint_mismatch" }; + const session = await resolveSessionRow(db, parsed.sessionToken); + if (!session) return { ok: false, reason: "session_invalid" }; + return { ok: true, session, sessionToken: parsed.sessionToken }; +} + +function personaForAge(age) { + if (age == null) return "kid-tutor"; + if (age <= 9) return "kid-tutor"; + if (age <= 13) return "tween-tutor"; + return "adult-tutor"; +} + +function ageBandFromGuestBand(band) { + const b = String(band || "").toLowerCase(); + if (b.includes("5-9")) return "kid-tutor"; + if (b.includes("10-13")) return "tween-tutor"; + return "adult-tutor"; +} + +export default function makerLabKioskRouter(/* dashboardAuth */) { + const router = Router(); + let db; + + router.use((req, res, next) => { + if (!db && createDbClient) db = createDbClient(); + if (!db) return res.status(500).json({ error: "db_unavailable" }); + next(); + }); + + // ─── /kiosk/r/:code — atomic redemption ───────────────────────────────── + + router.get("/kiosk/r/:code", async (req, res) => { + const code = String(req.params.code || "").toUpperCase().slice(0, 32); + if (!code) return res.status(400).send("Missing code."); + + const fp = fingerprint(req); + + // Atomic claim. Expiry check lives in the WHERE clause, not a read-then-write. + const r = await db.execute({ + sql: `UPDATE maker_redemption_codes + SET used_at = datetime('now'), claimed_by_fingerprint = ? + WHERE code = ? AND used_at IS NULL AND expires_at > datetime('now') + RETURNING session_token`, + args: [fp, code], + }); + if (!r.rows.length) { + return res.status(410).type("html").send(` + + Code not valid + +

This code isn't valid anymore.

+

Ask a grown-up to get a fresh code.

+ `); + } + const sessionToken = r.rows[0].session_token; + await db.execute({ + sql: `UPDATE maker_sessions SET kiosk_device_id = ? WHERE token = ?`, + args: [fp, sessionToken], + }); + + setSessionCookie(req, res, signCookie(sessionToken, fp)); + return res.redirect(302, "/kiosk/"); + }); + + // ─── /kiosk/ — Blockly surface ───────────────────────────────────────── + + router.get("/kiosk/", async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) { + if (guard.reason === "session_invalid") clearSessionCookie(req, res); + return res.status(401).type("html").send(` + + Ask a grown-up + +

Ask a grown-up to start a new session.

+

This kiosk doesn't have an active session right now.

+ `); + } + + const blocklyIndex = resolve(__dirname, "../public/blockly/index.html"); + if (!existsSync(blocklyIndex)) { + return res.type("html").send(` + + Maker Lab kiosk + +

Kiosk placeholder — the Blockly page is not built yet.

+ `); + } + res.sendFile(blocklyIndex); + }); + + // Blockly static assets served under /kiosk/blockly/* + router.get("/kiosk/blockly/*", async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).send("No session."); + const rel = req.path.replace(/^\/kiosk\/blockly\//, "").replace(/\.\./g, ""); + const full = resolve(__dirname, "../public/blockly", rel); + if (!full.startsWith(resolve(__dirname, "../public/blockly"))) { + return res.status(403).send("Nope."); + } + if (!existsSync(full)) return res.status(404).send("Not found."); + res.sendFile(full); + }); + + // ─── /kiosk/api/context ──────────────────────────────────────────────── + + router.get("/kiosk/api/context", async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + const s = guard.session; + let age = null; + try { + const m = JSON.parse(s.learner_metadata || "{}"); + age = typeof m.age === "number" ? m.age : null; + } catch {} + const persona = s.is_guest ? ageBandFromGuestBand(s.guest_age_band) : personaForAge(age); + // Activity touch + await db.execute({ + sql: `UPDATE maker_sessions SET last_activity_at = datetime('now'), idle_locked_at = NULL WHERE token = ?`, + args: [guard.sessionToken], + }); + res.json({ + persona, + state: s.state, + is_guest: !!s.is_guest, + hints_used: s.hints_used, + expires_at: s.expires_at, + idle_lock_min: s.idle_lock_min, + transcripts_on: !!s.transcripts_enabled_snapshot, + }); + }); + + // ─── /kiosk/api/lesson/:id ───────────────────────────────────────────── + + router.get("/kiosk/api/lesson/:id", async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + const id = String(req.params.id || "").replace(/[^\w-]/g, "").slice(0, 100); + if (!id) return res.status(400).json({ error: "bad_id" }); + + // Look in bundled curriculum first, then ~/.crow/bundles/maker-lab/curriculum/custom/. + const candidates = [ + resolve(__dirname, `../curriculum/age-5-9/${id}.json`), + resolve(__dirname, `../curriculum/age-10-13/${id}.json`), + resolve(__dirname, `../curriculum/age-14+/${id}.json`), + resolve(process.env.HOME || ".", `.crow/bundles/maker-lab/curriculum/custom/${id}.json`), + ]; + for (const p of candidates) { + if (existsSync(p)) { + try { + const lesson = JSON.parse(readFileSync(p, "utf8")); + return res.json({ lesson }); + } catch (err) { + return res.status(500).json({ error: "lesson_parse_error", detail: err.message }); + } + } + } + res.status(404).json({ error: "not_found" }); + }); + + // ─── /kiosk/api/progress ─────────────────────────────────────────────── + + router.post("/kiosk/api/progress", express_json(), async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + const s = guard.session; + if (s.state === "revoked") return res.status(410).json({ error: "revoked" }); + + const { surface, activity, outcome, note } = req.body || {}; + const allowed = ["started", "completed", "abandoned", "struggled"]; + if (!surface || !activity || !allowed.includes(outcome)) { + return res.status(400).json({ error: "bad_body" }); + } + + // Touch activity + await db.execute({ + sql: `UPDATE maker_sessions SET last_activity_at = datetime('now'), idle_locked_at = NULL WHERE token = ?`, + args: [guard.sessionToken], + }); + + if (s.is_guest || !s.learner_id) return res.json({ logged: false, reason: "guest" }); + + try { + await db.execute({ + sql: `INSERT INTO memories (content, context, category, importance, tags, project_id, source, created_at) + VALUES (?, ?, 'learning', 5, ?, ?, 'maker-lab', datetime('now'))`, + args: [ + String(note || `${outcome} on ${activity} in ${surface}`).slice(0, 2000), + `${String(surface).slice(0, 50)}:${String(activity).slice(0, 200)} — ${outcome}`, + `maker-lab,${surface},${outcome}`, + s.learner_id, + ], + }); + return res.json({ logged: true }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } + }); + + // ─── /kiosk/api/hint ─────────────────────────────────────────────────── + // + // Delegates to maker_hint via dynamic import of the factory's helpers. + // Phase 2.1 wires a shared module for LLM+filter; for now this endpoint + // returns a canned lesson hint appropriate to the persona (same as the + // MCP tool under the hood). + + router.post("/kiosk/api/hint", express_json(), async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + const s = guard.session; + if (s.state === "revoked") return res.status(410).json({ error: "revoked" }); + + const { surface, question, level, lesson_id, canned_hints } = req.body || {}; + if (!question || typeof question !== "string") { + return res.status(400).json({ error: "bad_question" }); + } + + // Activity touch + await db.execute({ + sql: `UPDATE maker_sessions SET last_activity_at = datetime('now'), idle_locked_at = NULL WHERE token = ?`, + args: [guard.sessionToken], + }); + + // Route through the shared hint pipeline (LLM + filter). Phase 2. + const { handleHintRequest } = await import(pathToFileURL(resolve(__dirname, "../server/hint-pipeline.js")).href); + try { + const result = await handleHintRequest(db, { + sessionToken: guard.sessionToken, + session: s, + surface: String(surface || "").slice(0, 50), + question: question.slice(0, 2000), + level: Math.min(3, Math.max(1, Number(level) || 1)), + lessonId: lesson_id ? String(lesson_id).slice(0, 100) : null, + cannedHints: Array.isArray(canned_hints) ? canned_hints.map((h) => String(h).slice(0, 500)).slice(0, 10) : null, + }); + return res.json(result); + } catch (err) { + return res.status(500).json({ error: "hint_failed", detail: err.message }); + } + }); + + // ─── /kiosk/api/end ──────────────────────────────────────────────────── + + router.post("/kiosk/api/end", async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + clearSessionCookie(req, res); + res.json({ ok: true }); + }); + + return router; +} + +// Minimal body-parser to avoid ordering concerns with the main app's JSON parser. +function express_json(limit = 64 * 1024) { + return (req, res, next) => { + if (req.method !== "POST" && req.method !== "PUT") return next(); + if (req.headers["content-type"] && !String(req.headers["content-type"]).includes("application/json")) { + return next(); + } + if (req.body && typeof req.body === "object") return next(); + let data = ""; + req.setEncoding("utf8"); + req.on("data", (c) => { + data += c; + if (data.length > limit) { + req.destroy(); + } + }); + req.on("end", () => { + try { req.body = data ? JSON.parse(data) : {}; } + catch { req.body = {}; } + next(); + }); + req.on("error", next); + }; +} diff --git a/bundles/maker-lab/public/blockly/index.html b/bundles/maker-lab/public/blockly/index.html new file mode 100644 index 0000000..1e1ba2b --- /dev/null +++ b/bundles/maker-lab/public/blockly/index.html @@ -0,0 +1,51 @@ + + + + + + +Maker Lab — Blockly + + + + + +
+
Loading…
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + diff --git a/bundles/maker-lab/public/blockly/kiosk.css b/bundles/maker-lab/public/blockly/kiosk.css new file mode 100644 index 0000000..b803892 --- /dev/null +++ b/bundles/maker-lab/public/blockly/kiosk.css @@ -0,0 +1,54 @@ +:root { + --bg: #fff8f0; + --fg: #222; + --accent: #84cc16; + --accent-2: #3b82f6; + --danger: #ef4444; + --muted: #6b7280; + --border: rgba(0,0,0,0.1); + --card: rgba(255,255,255,0.8); +} +@media (prefers-color-scheme: dark) { + :root { --bg:#0b1020; --fg:#f5f5f5; --border:rgba(255,255,255,0.12); --card:rgba(255,255,255,0.04); --muted:#9ca3af; } +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); overflow: hidden; } + +.top { + position: fixed; inset: 0 0 auto 0; height: 56px; + display: flex; align-items: center; gap: 0.75rem; + padding: 0 1rem; border-bottom: 1px solid var(--border); background: var(--card); backdrop-filter: blur(8px); + z-index: 10; +} +.lesson-title { font-size: 1.2rem; font-weight: 700; } +.status-chip { font-size: 0.8rem; color: var(--muted); padding: 0.15rem 0.5rem; border: 1px solid var(--border); border-radius: 999px; } +.spacer { flex: 1; } + +.btn { + border: 1px solid var(--border); background: transparent; color: inherit; + border-radius: 6px; padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; +} +.btn:active { transform: translateY(1px); } +.hint-btn { font-size: 1.4rem; font-weight: 700; width: 2.5rem; padding: 0; background: var(--accent-2); color: #fff; border-color: var(--accent-2); } +.done-btn { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } + +.layout { position: fixed; inset: 56px 0 0 0; } +#blocklyArea { width: 100%; height: 100%; } + +.hint-bubble { + position: fixed; right: 1rem; bottom: 1rem; + max-width: 360px; padding: 1rem; background: var(--card); backdrop-filter: blur(12px); + border: 2px solid var(--accent-2); border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.25); + z-index: 20; +} +.hint-text { font-size: 1.05rem; line-height: 1.4; margin-bottom: 0.75rem; } +.hint-close { background: var(--accent-2); color: #fff; border-color: var(--accent-2); } + +.offline-chip { + position: fixed; top: 70px; right: 1rem; + padding: 0.3rem 0.8rem; border-radius: 999px; + background: rgba(239,68,68,0.1); color: var(--danger); font-size: 0.8rem; + z-index: 15; +} diff --git a/bundles/maker-lab/public/blockly/tutor-bridge.js b/bundles/maker-lab/public/blockly/tutor-bridge.js new file mode 100644 index 0000000..3389b87 --- /dev/null +++ b/bundles/maker-lab/public/blockly/tutor-bridge.js @@ -0,0 +1,239 @@ +/** + * Maker Lab — tutor-bridge.js + * + * Client-side glue between the Blockly kiosk and the maker-lab HTTP API. + * + * Responsibilities: + * - Fetch session context + current lesson + * - Mount a Blockly workspace (minimal toolbox; curriculum-driven in follow-up) + * - Wire the "?" hint button → POST /kiosk/api/hint, render filtered text + * - Wire "I'm done!" → POST /kiosk/api/progress + * - Detect offline + queue progress POSTs in IndexedDB, replay on reconnect + * - Client-side idle activity hook (counts block-change events only) + * + * Phase 2 notes: + * - The client-side salt for the device fingerprint is stored in localStorage + * and echoed via x-maker-kiosk-salt on every request. + * - Real companion WS integration (tutor-event message) is stubbed until the + * companion backend patches land (bundles/companion/patches/backend/0001). + * For now the hint audio plays via the kiosk's own TTS (speechSynthesis). + */ + +const SALT_KEY = "maker-kiosk-salt"; +const QUEUE_DB = "maker-lab-queue"; +const QUEUE_STORE = "progress"; + +function ensureSalt() { + let s = localStorage.getItem(SALT_KEY); + if (!s) { + s = crypto.randomUUID(); + localStorage.setItem(SALT_KEY, s); + } + return s; +} + +function apiFetch(path, opts = {}) { + const headers = new Headers(opts.headers || {}); + headers.set("x-maker-kiosk-salt", ensureSalt()); + if (opts.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return fetch(path, { ...opts, headers, credentials: "same-origin" }); +} + +// ─── IndexedDB offline queue ─────────────────────────────────────────────── + +function openQueueDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(QUEUE_DB, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(QUEUE_STORE, { keyPath: "id", autoIncrement: true }); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function queuePush(payload) { + const db = await openQueueDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, "readwrite"); + tx.objectStore(QUEUE_STORE).add({ payload, at: Date.now() }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function queueDrain(postFn) { + const db = await openQueueDb(); + const tx = db.transaction(QUEUE_STORE, "readwrite"); + const store = tx.objectStore(QUEUE_STORE); + const all = await new Promise((r) => { store.getAll().onsuccess = (e) => r(e.target.result); }); + for (const row of all) { + try { + const res = await postFn(row.payload); + if (res.ok) { + store.delete(row.id); + } else { + break; // leave rest for next drain + } + } catch { + break; + } + } +} + +// ─── Hint UI ─────────────────────────────────────────────────────────────── + +const hintBtn = document.getElementById("hintBtn"); +const doneBtn = document.getElementById("doneBtn"); +const hintBubble = document.getElementById("hintBubble"); +const hintText = document.getElementById("hintText"); +const hintClose = document.getElementById("hintClose"); +const titleEl = document.getElementById("lessonTitle"); +const transcriptChip = document.getElementById("transcriptChip"); +const offlineChip = document.getElementById("offlineChip"); + +let hintLevel = 1; +let currentLesson = null; +let currentSurface = "blockly"; + +hintClose?.addEventListener("click", () => { + hintBubble.hidden = true; +}); + +function speak(text) { + try { + if ("speechSynthesis" in window && text) { + const u = new SpeechSynthesisUtterance(text); + u.rate = 0.95; + speechSynthesis.cancel(); + speechSynthesis.speak(u); + } + } catch { /* TTS is best-effort */ } +} + +async function requestHint() { + hintText.textContent = "Thinking…"; + hintBubble.hidden = false; + try { + const res = await apiFetch("/kiosk/api/hint", { + method: "POST", + body: JSON.stringify({ + surface: currentSurface, + question: "I need a hint.", + level: hintLevel, + lesson_id: currentLesson?.id || null, + canned_hints: currentLesson?.canned_hints || null, + }), + }); + if (!res.ok) { + hintText.textContent = "Your tutor is taking a nap. Try the lesson hints on your own for a minute!"; + return; + } + const data = await res.json(); + hintText.textContent = data.text; + speak(data.text); + hintLevel = Math.min(3, hintLevel + 1); // escalate next time + } catch { + hintText.textContent = "Your tutor is taking a nap. Try the lesson hints on your own for a minute!"; + } +} + +hintBtn?.addEventListener("click", requestHint); + +// ─── Progress ────────────────────────────────────────────────────────────── + +async function postProgress(payload) { + return apiFetch("/kiosk/api/progress", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +doneBtn?.addEventListener("click", async () => { + const payload = { + surface: currentSurface, + activity: currentLesson?.id || "unknown", + outcome: "completed", + note: null, + }; + try { + const res = await postProgress(payload); + if (!res.ok) throw new Error("http_" + res.status); + hintText.textContent = "Great job! 🎉"; + hintBubble.hidden = false; + hintLevel = 1; + } catch { + // Queue for when we come back online. + await queuePush(payload); + offlineChip.hidden = false; + } +}); + +// ─── Connectivity ────────────────────────────────────────────────────────── + +window.addEventListener("online", async () => { + offlineChip.hidden = true; + await queueDrain(postProgress); +}); +window.addEventListener("offline", () => { + offlineChip.hidden = false; +}); + +// ─── Boot ────────────────────────────────────────────────────────────────── + +async function loadContext() { + try { + const ctx = await (await apiFetch("/kiosk/api/context")).json(); + if (ctx.transcripts_on) { + transcriptChip.textContent = "Your grown-up might read our chat"; + } else { + transcriptChip.textContent = "This chat is private"; + } + } catch { /* non-fatal */ } +} + +async function loadLesson(id) { + try { + const r = await apiFetch(`/kiosk/api/lesson/${encodeURIComponent(id)}`); + if (!r.ok) return null; + const { lesson } = await r.json(); + currentLesson = lesson; + currentSurface = lesson.surface || "blockly"; + titleEl.textContent = lesson.title || id; + return lesson; + } catch { + return null; + } +} + +function mountBlockly() { + if (typeof Blockly === "undefined") { + titleEl.textContent = "Blockly couldn't load. Ask a grown-up to check the network."; + return null; + } + const toolbox = document.getElementById("toolbox"); + return Blockly.inject("blocklyArea", { + toolbox, + trashcan: true, + grid: { spacing: 20, length: 3, colour: "#ccc", snap: true }, + zoom: { controls: true, wheel: true, startScale: 1.1 }, + }); +} + +async function init() { + const urlLesson = new URLSearchParams(location.search).get("lesson") || "blockly-01-move-cat"; + await loadContext(); + await loadLesson(urlLesson); + const ws = mountBlockly(); + // Count workspace changes as activity (allowlist, per plan). + ws?.addChangeListener(() => { + // Best-effort heartbeat: light touch via context GET. + // Real activity tracking is server-side on any /kiosk/api/* hit. + }); + // Drain any queued progress from a previous offline spell. + if (navigator.onLine) { queueDrain(postProgress).catch(() => {}); } +} + +init(); diff --git a/registry/add-ons.json b/registry/add-ons.json index fb7e806..a34e128 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -17,6 +17,7 @@ "envKeys": ["MAKER_LAB_MODE", "MAKER_LAB_LLM_ENDPOINT", "MAKER_LAB_LLM_MODEL"] }, "panel": "panel/maker-lab.js", + "panelRoutes": "panel/routes.js", "skills": ["skills/maker-lab.md"], "requires": { "min_ram_mb": 256, diff --git a/servers/shared/kiosk-guard.js b/servers/shared/kiosk-guard.js new file mode 100644 index 0000000..1a46122 --- /dev/null +++ b/servers/shared/kiosk-guard.js @@ -0,0 +1,48 @@ +/** + * Kiosk-active guard. + * + * When any Maker Lab session is active (`state != 'revoked' AND expires_at > now()`), + * peer-sharing MCP surfaces refuse to run. Defense-in-depth for the plan rule + * "no peer-sharing ever initiated from inside a kid session." + * + * Safe on installs without maker-lab — returns `false` silently if the + * `maker_sessions` table doesn't exist. + */ + +let _cache = { at: 0, value: false }; +const CACHE_MS = 1000; + +export async function isKioskActive(db) { + const now = Date.now(); + if (now - _cache.at < CACHE_MS) return _cache.value; + try { + const r = await db.execute({ + sql: `SELECT 1 FROM maker_sessions + WHERE state != 'revoked' AND expires_at > datetime('now') + LIMIT 1`, + args: [], + }); + const active = r.rows.length > 0; + _cache = { at: now, value: active }; + return active; + } catch { + _cache = { at: now, value: false }; + return false; + } +} + +/** + * Return a McpServer-style error content payload explaining the refusal. + * Tools call this inside their handler when isKioskActive() is true. + */ +export function kioskBlockedResponse(toolName) { + return { + content: [ + { + type: "text", + text: `Refusing to run ${toolName}: a Maker Lab kiosk session is active. Peer-sharing is disabled while a learner is in a tutor session. End the session from the Maker Lab panel and try again.`, + }, + ], + isError: true, + }; +} diff --git a/servers/sharing/server.js b/servers/sharing/server.js index c591750..1fec809 100644 --- a/servers/sharing/server.js +++ b/servers/sharing/server.js @@ -29,6 +29,7 @@ import { z } from "zod"; import { createHash, randomBytes } from "node:crypto"; import { createDbClient } from "../db.js"; import { generateToken, validateToken, shouldSkipGates } from "../shared/confirm.js"; +import { isKioskActive, kioskBlockedResponse } from "../shared/kiosk-guard.js"; import { loadOrCreateIdentity, generateInviteCode, @@ -850,6 +851,7 @@ export function createSharingServer(dbPath, options = {}) { display_name: z.string().max(100).optional().describe("Optional display name for this contact"), }, async ({ display_name }) => { + if (await isKioskActive(db)) return kioskBlockedResponse("crow_generate_invite"); const code = generateInviteCode(identity); return { content: [ @@ -1040,6 +1042,7 @@ export function createSharingServer(dbPath, options = {}) { confirm_token: z.string().max(100).describe('Confirmation token — pass "" on first call to get a preview, then pass the returned token to execute'), }, async ({ contact, share_type, item_id, permissions, confirm_token }) => { + if (await isKioskActive(db)) return kioskBlockedResponse("crow_share"); // Find contact const result = await db.execute({ sql: "SELECT * FROM contacts WHERE (crow_id = ? OR display_name = ?) AND is_blocked = 0", @@ -1219,6 +1222,7 @@ export function createSharingServer(dbPath, options = {}) { message: z.string().max(10000).describe("Message text to send"), }, async ({ contact, message }) => { + if (await isKioskActive(db)) return kioskBlockedResponse("crow_send_message"); // Find contact const result = await db.execute({ sql: "SELECT * FROM contacts WHERE (crow_id = ? OR display_name = ?) AND is_blocked = 0", From d1f7dc1c4791891ce88a7b5aee47b637de338302 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 12:52:40 -0500 Subject: [PATCH 4/9] Maker Lab: Phase 2.1 panel flows (QR, bulk, session controls) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the admin panel's full session lifecycle: Start session, Bulk Start (printable QR sheet), Guest flow (age picker), and live session controls (End / Force End / Unlock Idle / Revoke Batch). Session minting (new bundles/maker-lab/server/sessions.js): - Single source of truth for mintSessionForLearner, mintGuestSession, mintBatchSessions. Both the MCP tools and the admin panel now call into the same functions — no duplicate INSERT paths. - Returns { sessionToken, redemptionCode, shortUrl, codeExpiresAt, sessionExpiresAt, learnerId, learnerName, batchId }. Panel (panel/maker-lab.js — full rewrite): - Three view modes share a single handler. Family: per-card Start + duration input. Classroom: multi-select checkboxes + Bulk Start form with batch_label + printable QR sheet. Solo: simplified tile. - Guest picker: "Try it without saving" → age band buttons (5-9 / 10-13 / 14+) → mint + redirect to QR handoff page. - QR handoff page: inlines SVG (via qrcode npm pkg, pinned 1.5.3), renders the redemption code + full URL + Print button. Uses CROW_GATEWAY_URL for the public URL embedded in the QR; falls back to relative if unset. - Batch sheet: grid of per-learner QR cards, Print button, revoke-batch form that requires a reason. - Active sessions section: End / Force End / Unlock Idle / batch link buttons on every active session. Pre-fetches the latest redemption code per session so the "QR" link on active cards works. - Error banner with friendly messages for each err= query param. Schema (maker_learner_settings): - Added age + avatar columns (research_projects has no metadata column; previous attempt to use one was a bug). - Boot-time addColumnIfMissing migrates existing installs safely. Refactor (server/server.js): - maker_start_session, maker_start_sessions_bulk, maker_start_guest_session delegate to sessions.js helpers. ~100 lines of duplicated INSERT logic removed. - maker_create_learner, maker_list_learners, maker_get_learner, maker_update_learner read/write age+avatar from maker_learner_settings. - server/filters.js getLearnerAge() reads from maker_learner_settings. - panel/routes.js /kiosk/api/context joins maker_learner_settings for age. Dep: - qrcode@^1.5.3 (pure JS, SVG output). Pinned for air-gap friendliness. End-to-end sanity-checked against live DB: create_learner → list_learners → start_session returns well-formed redemption_code + short_url. --- bundles/maker-lab/package-lock.json | 306 ++++++++++ bundles/maker-lab/package.json | 1 + bundles/maker-lab/panel/maker-lab.js | 753 ++++++++++++++++++------ bundles/maker-lab/panel/routes.js | 9 +- bundles/maker-lab/server/filters.js | 17 +- bundles/maker-lab/server/init-tables.js | 18 +- bundles/maker-lab/server/server.js | 228 +++---- bundles/maker-lab/server/sessions.js | 128 ++++ 8 files changed, 1116 insertions(+), 344 deletions(-) create mode 100644 bundles/maker-lab/server/sessions.js diff --git a/bundles/maker-lab/package-lock.json b/bundles/maker-lab/package-lock.json index 073f1d3..8c7f9a6 100644 --- a/bundles/maker-lab/package-lock.json +++ b/bundles/maker-lab/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@libsql/client": "^0.14.0", "@modelcontextprotocol/sdk": "^1.12.0", + "qrcode": "^1.5.3", "zod": "^3.24.0" } }, @@ -279,6 +280,30 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -341,6 +366,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -438,6 +501,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -456,6 +528,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -476,6 +554,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -678,6 +762,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -717,6 +814,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -859,6 +965,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -927,6 +1042,18 @@ "@libsql/win32-x64-msvc": "0.4.7" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1077,6 +1204,42 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1086,6 +1249,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1114,6 +1286,15 @@ "node": ">=16.20.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/promise-limit": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", @@ -1133,6 +1314,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -1172,6 +1370,15 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1181,6 +1388,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1248,6 +1461,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1356,6 +1575,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1427,6 +1672,26 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1454,6 +1719,47 @@ } } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/bundles/maker-lab/package.json b/bundles/maker-lab/package.json index 1890094..f1943c4 100644 --- a/bundles/maker-lab/package.json +++ b/bundles/maker-lab/package.json @@ -6,6 +6,7 @@ "dependencies": { "@libsql/client": "^0.14.0", "@modelcontextprotocol/sdk": "^1.12.0", + "qrcode": "^1.5.3", "zod": "^3.24.0" } } diff --git a/bundles/maker-lab/panel/maker-lab.js b/bundles/maker-lab/panel/maker-lab.js index 31a81e7..143be0b 100644 --- a/bundles/maker-lab/panel/maker-lab.js +++ b/bundles/maker-lab/panel/maker-lab.js @@ -1,12 +1,29 @@ /** - * Crow's Nest Panel — Maker Lab (Phase 1 scaffold) + * Crow's Nest Panel — Maker Lab (Phase 2.1) * - * Three view modes (solo / family / classroom), guest "Try it" button, - * minimal learner management. Lesson authoring UI lands in Phase 2. + * Views by mode: + * solo — "Continue learning" tile + settings + * family — learner list, per-card Start session + * classroom — learner grid, multi-select, Bulk Start, printable batch sheet + * guest — "Try it" age-picker (available from any mode) * - * Handler pattern copied from bundles/knowledge-base/panel/knowledge-base.js. + * Session views: + * ?start= → mint + render QR handoff page + * ?bulk=1 (POST) → mint batch + render printable sheet + * ?batch= → view + revoke batch + * ?guest=1 → age picker → mint guest + QR page + * ?session= → live session controls (end / force end) + * + * Handler pattern follows bundles/knowledge-base/panel/knowledge-base.js. */ +import { pathToFileURL } from "node:url"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import QRCode from "qrcode"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + export default { id: "maker-lab", name: "Maker Lab", @@ -16,12 +33,14 @@ export default { category: "education", async handler(req, res, { db, layout, appRoot }) { - const { pathToFileURL } = await import("node:url"); - const { join } = await import("node:path"); const componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js"); const { escapeHtml } = await import(pathToFileURL(componentsPath).href); - // Resolve current mode (solo/family/classroom) from dashboard_settings. + const sessionsMod = await import(pathToFileURL(resolve(__dirname, "../server/sessions.js")).href); + const { mintSessionForLearner, mintGuestSession, mintBatchSessions } = sessionsMod; + + // ─── Helpers ───────────────────────────────────────────────────────── + async function getMode() { const r = await db.execute({ sql: "SELECT value FROM dashboard_settings WHERE key = 'maker_lab.mode'", @@ -38,7 +57,32 @@ export default { }); } - // ─── POST actions ─────────────────────────────────────────────────── + function publicBaseUrl() { + // CROW_GATEWAY_URL is set by the installer to the Tailscale / LAN hostname. + // If unset, we emit relative URLs — kiosks on the same network see the + // gateway at whatever host they loaded the QR from. + return (process.env.CROW_GATEWAY_URL || "").replace(/\/$/, ""); + } + + function fullKioskUrl(shortUrl) { + const base = publicBaseUrl(); + return base ? `${base}${shortUrl}` : shortUrl; + } + + async function renderQrSvg(url) { + try { + return await QRCode.toString(url, { + type: "svg", + errorCorrectionLevel: "M", + margin: 1, + width: 220, + }); + } catch { + return ""; + } + } + + // ─── POST actions ──────────────────────────────────────────────────── if (req.method === "POST") { const a = req.body?.action; @@ -46,7 +90,6 @@ export default { if (a === "set_mode") { const mode = String(req.body.mode || "family"); if (["solo", "family", "classroom"].includes(mode)) { - // Solo-downgrade guard: refuse if more than one learner exists. if (mode === "solo") { const c = await db.execute({ sql: "SELECT COUNT(*) AS n FROM research_projects WHERE type='learner_profile'", @@ -72,17 +115,16 @@ export default { if (!consent) { return res.redirect("/dashboard/maker-lab?err=consent_required"); } - const meta = JSON.stringify({ age, avatar }); const ins = await db.execute({ - sql: `INSERT INTO research_projects (name, type, description, metadata, created_at, updated_at) - VALUES (?, 'learner_profile', ?, ?, datetime('now'), datetime('now')) RETURNING id`, - args: [name, null, meta], + sql: `INSERT INTO research_projects (name, type, description, created_at, updated_at) + VALUES (?, 'learner_profile', ?, datetime('now'), datetime('now')) RETURNING id`, + args: [name, null], }); const lid = Number(ins.rows[0].id); await db.execute({ - sql: `INSERT INTO maker_learner_settings (learner_id, consent_captured_at) - VALUES (?, datetime('now'))`, - args: [lid], + sql: `INSERT INTO maker_learner_settings (learner_id, age, avatar, consent_captured_at) + VALUES (?, ?, ?, datetime('now'))`, + args: [lid, age, avatar], }); return res.redirect(`/dashboard/maker-lab?created=${lid}`); } @@ -90,7 +132,6 @@ export default { if (a === "delete_learner") { const lid = Number(req.body.learner_id); if (!Number.isFinite(lid)) return res.redirect("/dashboard/maker-lab"); - // Tier-1: require explicit confirm step via ?confirm=DELETE in POST body. if (req.body.confirm !== "DELETE") { return res.redirect(`/dashboard/maker-lab?pending_delete=${lid}`); } @@ -106,19 +147,170 @@ export default { return res.redirect("/dashboard/maker-lab?deleted=1"); } - // Minting sessions actually happens via the MCP tool; the panel only - // renders the redemption code / short URL on return. Phase 2 wires the - // QR-code image rendering. + if (a === "start_session") { + const lid = Number(req.body.learner_id); + const duration = Math.max(5, Math.min(240, Number(req.body.duration_min) || 60)); + const idle = req.body.idle_lock_min ? Math.max(0, Math.min(240, Number(req.body.idle_lock_min))) : undefined; + try { + const r = await mintSessionForLearner(db, { + learnerId: lid, durationMin: duration, idleLockMin: idle, + }); + return res.redirect(`/dashboard/maker-lab?qr=${encodeURIComponent(r.redemptionCode)}`); + } catch (err) { + return res.redirect(`/dashboard/maker-lab?err=${encodeURIComponent(err.code || "mint_failed")}`); + } + } + + if (a === "bulk_start") { + const raw = req.body.learner_ids; + const ids = (Array.isArray(raw) ? raw : [raw]) + .map((x) => Number(x)) + .filter((n) => Number.isFinite(n) && n > 0); + if (!ids.length) return res.redirect("/dashboard/maker-lab?err=no_learners"); + const duration = Math.max(5, Math.min(240, Number(req.body.duration_min) || 60)); + const idle = req.body.idle_lock_min ? Math.max(0, Math.min(240, Number(req.body.idle_lock_min))) : undefined; + const label = String(req.body.batch_label || "").trim().slice(0, 200) || null; + const { batchId } = await mintBatchSessions(db, { + learnerIds: ids, durationMin: duration, idleLockMin: idle, batchLabel: label, + }); + return res.redirect(`/dashboard/maker-lab?batch=${encodeURIComponent(batchId)}`); + } + + if (a === "start_guest") { + const band = ["5-9", "10-13", "14+"].includes(String(req.body.age_band)) + ? String(req.body.age_band) : "5-9"; + const r = await mintGuestSession(db, { ageBand: band }); + return res.redirect(`/dashboard/maker-lab?qr=${encodeURIComponent(r.redemptionCode)}&guest=1`); + } + + if (a === "end_session") { + const token = String(req.body.session_token || ""); + if (token) { + await db.execute({ + sql: `UPDATE maker_sessions SET state='ending', ending_started_at=datetime('now') + WHERE token=? AND state='active'`, + args: [token], + }); + setTimeout(async () => { + try { + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [token], + }); + } catch {} + }, 5000); + } + return res.redirect("/dashboard/maker-lab"); + } + + if (a === "force_end") { + const token = String(req.body.session_token || ""); + const reason = String(req.body.reason || "admin_force").slice(0, 500); + if (!token || reason.length < 3) return res.redirect("/dashboard/maker-lab?err=reason_required"); + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') WHERE token=?`, + args: [token], + }); + return res.redirect("/dashboard/maker-lab"); + } + + if (a === "unlock_idle") { + const token = String(req.body.session_token || ""); + if (token) { + await db.execute({ + sql: `UPDATE maker_sessions SET idle_locked_at=NULL, last_activity_at=datetime('now') WHERE token=?`, + args: [token], + }); + } + return res.redirect("/dashboard/maker-lab"); + } + + if (a === "revoke_batch") { + const batchId = String(req.body.batch_id || ""); + const reason = String(req.body.reason || "").slice(0, 500); + if (!batchId || reason.length < 3) return res.redirect("/dashboard/maker-lab?err=reason_required"); + await db.execute({ + sql: `UPDATE maker_sessions SET state='revoked', revoked_at=datetime('now') + WHERE batch_id=? AND state != 'revoked'`, + args: [batchId], + }); + await db.execute({ + sql: `UPDATE maker_batches SET revoked_at=datetime('now'), revoke_reason=? WHERE batch_id=?`, + args: [reason, batchId], + }); + return res.redirect("/dashboard/maker-lab?revoked_batch=" + encodeURIComponent(batchId)); + } + } + + // ─── GET: specialized views ────────────────────────────────────────── + + // QR handoff page (single session) + if (req.query.qr) { + const code = String(req.query.qr).toUpperCase().slice(0, 32); + const r = await db.execute({ + sql: `SELECT c.*, s.is_guest, s.learner_id, s.expires_at AS session_expires_at, + rp.name AS learner_name + FROM maker_redemption_codes c + JOIN maker_sessions s ON s.token = c.session_token + LEFT JOIN research_projects rp ON rp.id = s.learner_id + WHERE c.code = ?`, + args: [code], + }); + if (!r.rows.length) { + return layout({ title: "Code not found", content: `

That redemption code doesn't exist.

Back` }); + } + const row = r.rows[0]; + const shortUrl = `/kiosk/r/${code}`; + const fullUrl = fullKioskUrl(shortUrl); + const qrSvg = await renderQrSvg(fullUrl); + const title = row.is_guest ? "Guest session" : `Session for ${row.learner_name || "learner"}`; + return layout({ + title, + content: renderQrPage({ code, shortUrl, fullUrl, qrSvg, row, escapeHtml }), + }); + } + + // Batch sheet view (printable) + if (req.query.batch) { + const batchId = String(req.query.batch).slice(0, 64); + const [bRes, sRes] = await Promise.all([ + db.execute({ sql: "SELECT * FROM maker_batches WHERE batch_id=?", args: [batchId] }), + db.execute({ + sql: `SELECT c.code, c.expires_at AS code_expires_at, + s.token, s.learner_id, s.expires_at AS session_expires_at, s.state, + rp.name AS learner_name + FROM maker_sessions s + JOIN maker_redemption_codes c ON c.session_token = s.token + LEFT JOIN research_projects rp ON rp.id = s.learner_id + WHERE s.batch_id = ? + ORDER BY rp.name`, + args: [batchId], + }), + ]); + if (!bRes.rows.length) { + return layout({ title: "Batch not found", content: `Back` }); + } + const batch = bRes.rows[0]; + const rows = await Promise.all(sRes.rows.map(async (r) => ({ + ...r, + qrSvg: await renderQrSvg(fullKioskUrl(`/kiosk/r/${r.code}`)), + }))); + return layout({ + title: `Batch: ${batch.label || batch.batch_id.slice(0, 8)}`, + content: renderBatchSheet({ batch, rows, escapeHtml, fullKioskUrl, publicBaseUrl }), + }); } - // ─── GET ──────────────────────────────────────────────────────────── + // ─── GET: main view ────────────────────────────────────────────────── const mode = await getMode(); const err = String(req.query.err || ""); const pendingDelete = req.query.pending_delete ? Number(req.query.pending_delete) : null; + const showGuestPicker = req.query.guest === "pick"; const learnersR = await db.execute({ - sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, + sql: `SELECT rp.id, rp.name, rp.created_at, + mls.age, mls.avatar, mls.transcripts_enabled, mls.consent_captured_at FROM research_projects rp LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id @@ -126,167 +318,386 @@ export default { ORDER BY rp.created_at DESC`, args: [], }); - const learners = learnersR.rows.map((r) => { - let meta = {}; - try { meta = JSON.parse(r.metadata || "{}"); } catch {} - return { - id: Number(r.id), - name: r.name, - age: meta.age ?? null, - persona: meta.age == null ? "kid-tutor" - : meta.age <= 9 ? "kid-tutor" - : meta.age <= 13 ? "tween-tutor" - : "adult-tutor", - transcripts_enabled: !!r.transcripts_enabled, - consent_captured_at: r.consent_captured_at, - created_at: r.created_at, - }; - }); + const learners = learnersR.rows.map((r) => ({ + id: Number(r.id), name: r.name, + age: r.age ?? null, + avatar: r.avatar ?? null, + persona: r.age == null ? "kid-tutor" + : r.age <= 9 ? "kid-tutor" + : r.age <= 13 ? "tween-tutor" + : "adult-tutor", + transcripts_enabled: !!r.transcripts_enabled, + consent_captured_at: r.consent_captured_at, + })); const activeSessionsR = await db.execute({ - sql: `SELECT token, learner_id, started_at, expires_at, state, hints_used - FROM maker_sessions - WHERE state != 'revoked' AND expires_at > datetime('now') AND is_guest = 0 - ORDER BY started_at DESC LIMIT 50`, + sql: `SELECT s.token, s.learner_id, s.is_guest, s.guest_age_band, s.batch_id, + s.started_at, s.expires_at, s.state, s.hints_used, + s.idle_locked_at, s.last_activity_at, + rp.name AS learner_name + FROM maker_sessions s + LEFT JOIN research_projects rp ON rp.id = s.learner_id + WHERE s.state != 'revoked' AND s.expires_at > datetime('now') + ORDER BY s.started_at DESC LIMIT 50`, args: [], }); + const allActive = activeSessionsR.rows; + // Pre-fetch the latest unused (or most-recent) redemption code per active + // session so the "QR" button on live cards can link to the handoff page. + const tokenList = allActive.map((s) => s.token); + const codesByToken = new Map(); + if (tokenList.length) { + const placeholders = tokenList.map(() => "?").join(","); + const codesR = await db.execute({ + sql: `SELECT session_token, code, created_at FROM maker_redemption_codes + WHERE session_token IN (${placeholders}) + ORDER BY created_at DESC`, + args: tokenList, + }); + for (const row of codesR.rows) { + if (!codesByToken.has(row.session_token)) { + codesByToken.set(row.session_token, row.code); + } + } + } const activeByLearner = new Map(); - for (const s of activeSessionsR.rows) { - activeByLearner.set(Number(s.learner_id), s); + for (const s of allActive) { + s.redemption_code = codesByToken.get(s.token) || null; + if (s.learner_id != null) activeByLearner.set(Number(s.learner_id), s); } - // ─── Render ───────────────────────────────────────────────────────── + const batchesR = await db.execute({ + sql: `SELECT batch_id, label, created_at, revoked_at FROM maker_batches + ORDER BY created_at DESC LIMIT 10`, + args: [], + }); - const modeTabs = ["solo", "family", "classroom"].map((m) => ` -
- - - -
- `).join(""); - - const errBanner = err ? `` : ""; - - const createForm = ` -
- + Add learner -
- - - - - - -
-
- `; + const content = renderMainView({ + mode, err, pendingDelete, showGuestPicker, + learners, allActive, activeByLearner, batches: batchesR.rows, + escapeHtml, + }); - const guestButton = ` - Try it without saving → - `; + return layout({ title: `Maker Lab (${mode})`, content }); + }, +}; + +// ─── Render: QR handoff page ────────────────────────────────────────────── - const renderLearnerCard = (l) => { - const active = activeByLearner.get(l.id); - const isPending = pendingDelete === l.id; - return ` -
-
- ${escapeHtml(l.name)} - age ${l.age ?? '—'} - ${escapeHtml(l.persona)} - ${l.transcripts_enabled ? 'transcripts on' : ''} - ${active ? `live session` : ''} -
-
- ${active - ? `ends ${escapeHtml(active.expires_at || '')}` - : `Start session`} - ${isPending - ? `
- - - - -
- Cancel` - : `
- - - -
`} -
+function renderQrPage({ code, shortUrl, fullUrl, qrSvg, row, escapeHtml }) { + const subject = row.is_guest ? "Guest session" : `Session for ${row.learner_name || "learner"}`; + return ` + +
+
${qrSvg || 'QR render failed'}
+
+

${escapeHtml(subject)}

+
${escapeHtml(code)}
+
${escapeHtml(fullUrl)}
+
Code expires ${escapeHtml(row.expires_at)}. Session expires ${escapeHtml(row.session_expires_at)}.
+
Scan the QR with any camera, or type the code at /kiosk/r/CODE on the kiosk.
+
+ Back +
- `; - }; - - const learnersHtml = mode === "classroom" - ? `
${learners.map(renderLearnerCard).join("")}
` - : `
${learners.map(renderLearnerCard).join("")}
`; - - const modeHeadline = ({ - solo: "Solo mode — one learner, auto-start.", - family: "Family mode — per-learner progress timeline.", - classroom: "Classroom mode — grid view, bulk start, printable QR sheet.", - })[mode]; - - const startHint = req.query.start - ? `` - : ""; - - const phaseBanner = ``; - - const css = ``; - - const content = ` -
- ${css} - ${phaseBanner} - ${errBanner} - ${startHint} -
${modeTabs}${guestButton}
-
${modeHeadline}
- ${createForm} - ${learnersHtml || '
No learners yet. Add one above to get started.
'} + @media print { .sheet-header button, .btn, .revoke-form { display: none !important; } } + +
+
+

${escapeHtml(batch.label || `Batch ${batch.batch_id.slice(0, 8)}`)}

+
+ + Back +
+
+ ${revoked ? `
Revoked at ${escapeHtml(batch.revoked_at)}${batch.revoke_reason ? ` — ${escapeHtml(batch.revoke_reason)}` : ''}
` : ''} +
${cards || '

(no sessions in this batch)

'}
+ ${!revoked ? ` +
+ + + + +
+ ` : ''} +
+ `; +} + +// ─── Render: main view ──────────────────────────────────────────────────── + +function renderMainView({ mode, err, pendingDelete, showGuestPicker, learners, allActive, activeByLearner, batches, escapeHtml }) { + const errMsgs = { + create_invalid: "Name is required and age must be between 3 and 100.", + consent_required: "Consent checkbox is required.", + solo_multiple_learners: "Cannot downgrade to Solo mode with more than one learner.", + reason_required: "Reason is required (at least 3 chars).", + no_learners: "Pick at least one learner to start a batch.", + learner_not_found: "That learner doesn't exist.", + }; + const errBanner = err ? `` : ""; + + const modeTabs = ["solo", "family", "classroom"].map((m) => ` +
+ + + +
+ `).join(""); + + const guestSection = showGuestPicker ? ` +
+
+ + Try it without saving — pick an age: +
+ + + + Cancel +
+
+
+ ` : ` + Try it without saving → + `; + + const createForm = ` +
+ + Add learner +
+ + + + + + +
+
+ `; + + const renderLearnerCard = (l) => { + const active = activeByLearner.get(l.id); + const isPending = pendingDelete === l.id; + return ` +
+ ${mode === 'classroom' ? `` : ''} +
+ ${escapeHtml(l.name)} + age ${l.age ?? '—'} + ${escapeHtml(l.persona)} + ${l.transcripts_enabled ? 'transcripts on' : ''} + ${active ? `live session` : ''} +
+
+ ${active ? ` +
+ + + +
+ ${active.redemption_code ? `QR` : ''} + ` : ` +
+ + + + +
+ `} + ${isPending ? ` +
+ + + + +
+ Cancel + ` : ` +
+ + + +
+ `} +
`; + }; - return layout({ title: `Maker Lab (${mode})`, content }); - }, -}; + const learnersHtml = mode === "classroom" + ? `
${learners.map(renderLearnerCard).join("")}
` + : `
${learners.map(renderLearnerCard).join("")}
`; + + const bulkForm = mode === "classroom" ? ` +
+ + Start a batch for selected learners: + + + +
+ ` : ''; + + const activeList = allActive.length ? ` +
+ Active sessions (${allActive.length}) +
    + ${allActive.map((s) => ` +
  • + ${escapeHtml(s.learner_name || (s.is_guest ? `Guest (${s.guest_age_band})` : '?'))} + ${s.state} + ${s.hints_used || 0} hints · expires ${escapeHtml(s.expires_at)} + +
    + + + +
    + ${s.idle_locked_at ? ` +
    + + + +
    + ` : ''} +
    + + + + +
    + ${s.batch_id ? `Batch` : ''} +
    +
  • + `).join("")} +
+
+ ` : ''; + + const batchList = batches.length ? ` +
+ Recent batches (${batches.length}) +
    + ${batches.map((b) => ` +
  • + ${escapeHtml(b.label || b.batch_id.slice(0, 8))} + ${escapeHtml(b.created_at)}${b.revoked_at ? ` · revoked ${escapeHtml(b.revoked_at)}` : ''} + Open +
  • + `).join("")} +
+
+ ` : ''; + + const modeHeadline = ({ + solo: "Solo mode — one learner, auto-start.", + family: "Family mode — per-learner Start session.", + classroom: "Classroom mode — multi-select learners, then Bulk Start for a printable QR sheet.", + })[mode]; + + return ` +
+ ${css()} + ${errBanner} +
${modeTabs}${guestSection.includes('guest-btn') ? guestSection : ''}
+ ${guestSection.includes('guest-btn') ? '' : guestSection} +
${modeHeadline}
+ ${createForm} + ${bulkForm} + ${learnersHtml || '
No learners yet. Add one above to get started.
'} + ${activeList} + ${batchList} +
+ `; +} + +function css() { + return ``; +} diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js index 07e75ef..942660e 100644 --- a/bundles/maker-lab/panel/routes.js +++ b/bundles/maker-lab/panel/routes.js @@ -112,9 +112,10 @@ function clearSessionCookie(req, res) { async function resolveSessionRow(db, token) { if (!token) return null; const r = await db.execute({ - sql: `SELECT s.*, rp.name AS learner_name, rp.metadata AS learner_metadata + sql: `SELECT s.*, rp.name AS learner_name, mls.age AS learner_age FROM maker_sessions s LEFT JOIN research_projects rp ON rp.id = s.learner_id + LEFT JOIN maker_learner_settings mls ON mls.learner_id = s.learner_id WHERE s.token = ?`, args: [token], }); @@ -248,11 +249,7 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { const guard = await requireKioskSession(req, db); if (!guard.ok) return res.status(401).json({ error: guard.reason }); const s = guard.session; - let age = null; - try { - const m = JSON.parse(s.learner_metadata || "{}"); - age = typeof m.age === "number" ? m.age : null; - } catch {} + const age = typeof s.learner_age === "number" ? s.learner_age : null; const persona = s.is_guest ? ageBandFromGuestBand(s.guest_age_band) : personaForAge(age); // Activity touch await db.execute({ diff --git a/bundles/maker-lab/server/filters.js b/bundles/maker-lab/server/filters.js index cd97d77..f85cbbe 100644 --- a/bundles/maker-lab/server/filters.js +++ b/bundles/maker-lab/server/filters.js @@ -143,17 +143,14 @@ export function rateLimitCheck(token, limitPerMin = HINT_RATE_PER_MIN) { export async function getLearnerAge(db, learnerId) { if (!learnerId) return null; - const r = await db.execute({ - sql: `SELECT metadata FROM research_projects WHERE id=? AND type='learner_profile'`, - args: [learnerId], - }); - if (!r.rows.length) return null; try { - const meta = JSON.parse(r.rows[0].metadata || "{}"); - return typeof meta.age === "number" ? meta.age : null; - } catch { - return null; - } + const r = await db.execute({ + sql: `SELECT age FROM maker_learner_settings WHERE learner_id=?`, + args: [learnerId], + }); + if (r.rows.length && typeof r.rows[0].age === "number") return r.rows[0].age; + } catch {} + return null; } export async function resolvePersonaForSession(db, session) { diff --git a/bundles/maker-lab/server/init-tables.js b/bundles/maker-lab/server/init-tables.js index 239e01e..9fe6e2a 100644 --- a/bundles/maker-lab/server/init-tables.js +++ b/bundles/maker-lab/server/init-tables.js @@ -110,10 +110,13 @@ export async function initMakerLabTables(db) { CREATE INDEX IF NOT EXISTS idx_maker_transcripts_created ON maker_transcripts(created_at); `); - // Per-learner settings. + // Per-learner settings. Also stores age + avatar — research_projects + // doesn't have a metadata column, so learner attributes live here. await initTable(db, "maker_learner_settings", ` CREATE TABLE IF NOT EXISTS maker_learner_settings ( learner_id INTEGER PRIMARY KEY REFERENCES research_projects(id) ON DELETE CASCADE, + age INTEGER, + avatar TEXT, transcripts_enabled INTEGER NOT NULL DEFAULT 0, transcripts_retention_days INTEGER NOT NULL DEFAULT 30, idle_lock_default_min INTEGER, @@ -124,6 +127,19 @@ export async function initMakerLabTables(db) { ); `); + // Migration for existing installs created before age/avatar were added. + async function addColumnIfMissing(table, col, decl) { + try { + const r = await db.execute(`PRAGMA table_info(${table})`); + const cols = new Set(r.rows.map((x) => x.name)); + if (!cols.has(col)) { + await db.execute(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`); + } + } catch {} + } + await addColumnIfMissing("maker_learner_settings", "age", "INTEGER"); + await addColumnIfMissing("maker_learner_settings", "avatar", "TEXT"); + // Boot-time sweep: remove orphaned guest sessions from a crash. try { await db.execute("DELETE FROM maker_sessions WHERE is_guest = 1 AND (revoked_at IS NOT NULL OR state = 'revoked' OR expires_at < datetime('now'))"); diff --git a/bundles/maker-lab/server/server.js b/bundles/maker-lab/server/server.js index 7c9222a..0c29811 100644 --- a/bundles/maker-lab/server/server.js +++ b/bundles/maker-lab/server/server.js @@ -16,7 +16,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { randomBytes, randomUUID } from "node:crypto"; import { personaForAge, ageBandFromGuestBand, @@ -25,30 +24,16 @@ import { filterHint, } from "./filters.js"; import { handleHintRequest } from "./hint-pipeline.js"; +import { + SESSION_DEFAULT_MIN, + SESSION_MAX_MIN, + mintSessionForLearner, + mintGuestSession, + mintBatchSessions, +} from "./sessions.js"; -// ─── Constants ──────────────────────────────────────────────────────────── - -const SESSION_DEFAULT_MIN = 60; -const SESSION_MAX_MIN = 240; -const GUEST_MAX_MIN = 30; -const CODE_TTL_MIN = 10; const ENDING_FLUSH_SEC = 5; -function mintToken() { - return randomBytes(24).toString("base64url"); -} - -function mintRedemptionCode() { - const A = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O - const N = "23456789"; // no 0, 1 - const pick = (s) => s[Math.floor(Math.random() * s.length)]; - return `${pick(A)}${pick(A)}${pick(A)}-${pick(N)}${pick(N)}${pick(N)}`; -} - -function addMinutesISO(min) { - return new Date(Date.now() + min * 60_000).toISOString(); -} - // ─── Session resolution ─────────────────────────────────────────────────── function mcpError(msg) { @@ -115,17 +100,16 @@ export function createMakerLabServer(db, options = {}) { }, async ({ name, age, avatar, notes }) => { try { - const meta = JSON.stringify({ age, avatar: avatar || null, notes: notes || null }); const res = await db.execute({ - sql: `INSERT INTO research_projects (name, type, description, metadata, created_at, updated_at) - VALUES (?, 'learner_profile', ?, ?, datetime('now'), datetime('now')) RETURNING id`, - args: [name, notes || null, meta], + sql: `INSERT INTO research_projects (name, type, description, created_at, updated_at) + VALUES (?, 'learner_profile', ?, datetime('now'), datetime('now')) RETURNING id`, + args: [name, notes || null], }); const learnerId = Number(res.rows[0].id); await db.execute({ - sql: `INSERT INTO maker_learner_settings (learner_id, consent_captured_at) - VALUES (?, datetime('now'))`, - args: [learnerId], + sql: `INSERT INTO maker_learner_settings (learner_id, age, avatar, consent_captured_at) + VALUES (?, ?, ?, datetime('now'))`, + args: [learnerId, age, avatar || null], }); return mcpOk({ learner_id: learnerId, name, age, persona: personaForAge(age) }); } catch (err) { @@ -140,7 +124,8 @@ export function createMakerLabServer(db, options = {}) { {}, async () => { const r = await db.execute({ - sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, + sql: `SELECT rp.id, rp.name, rp.created_at, + mls.age, mls.avatar, mls.transcripts_enabled, mls.consent_captured_at FROM research_projects rp LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id @@ -148,19 +133,16 @@ export function createMakerLabServer(db, options = {}) { ORDER BY rp.created_at DESC`, args: [], }); - const learners = r.rows.map((row) => { - let meta = {}; - try { meta = JSON.parse(row.metadata || "{}"); } catch {} - return { - learner_id: Number(row.id), - name: row.name, - age: meta.age ?? null, - persona: personaForAge(meta.age), - transcripts_enabled: !!row.transcripts_enabled, - consent_captured_at: row.consent_captured_at, - created_at: row.created_at, - }; - }); + const learners = r.rows.map((row) => ({ + learner_id: Number(row.id), + name: row.name, + age: row.age ?? null, + avatar: row.avatar ?? null, + persona: personaForAge(row.age), + transcripts_enabled: !!row.transcripts_enabled, + consent_captured_at: row.consent_captured_at, + created_at: row.created_at, + })); return mcpOk({ learners }); } ); @@ -171,7 +153,7 @@ export function createMakerLabServer(db, options = {}) { { learner_id: z.number().int().positive() }, async ({ learner_id }) => { const r = await db.execute({ - sql: `SELECT rp.id, rp.name, rp.metadata, rp.created_at, mls.* + sql: `SELECT rp.id, rp.name, rp.created_at, mls.* FROM research_projects rp LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id WHERE rp.id = ? AND rp.type = 'learner_profile'`, @@ -179,14 +161,12 @@ export function createMakerLabServer(db, options = {}) { }); if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); const row = r.rows[0]; - let meta = {}; - try { meta = JSON.parse(row.metadata || "{}"); } catch {} return mcpOk({ learner_id: Number(row.id), name: row.name, - age: meta.age ?? null, - avatar: meta.avatar ?? null, - persona: personaForAge(meta.age), + age: row.age ?? null, + avatar: row.avatar ?? null, + persona: personaForAge(row.age), transcripts_enabled: !!row.transcripts_enabled, transcripts_retention_days: row.transcripts_retention_days ?? 30, idle_lock_default_min: row.idle_lock_default_min, @@ -215,26 +195,19 @@ export function createMakerLabServer(db, options = {}) { async (args) => { const { learner_id } = args; const r = await db.execute({ - sql: `SELECT metadata FROM research_projects WHERE id=? AND type='learner_profile'`, + sql: `SELECT id FROM research_projects WHERE id=? AND type='learner_profile'`, args: [learner_id], }); if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); - let meta = {}; - try { meta = JSON.parse(r.rows[0].metadata || "{}"); } catch {} - if (args.age != null) meta.age = args.age; - if (args.avatar != null) meta.avatar = args.avatar; - - const sets = ["metadata=?, updated_at=datetime('now')"]; - const sqlArgs = [JSON.stringify(meta)]; - if (args.name != null) { sets.push("name=?"); sqlArgs.push(args.name); } - sqlArgs.push(learner_id); - await db.execute({ - sql: `UPDATE research_projects SET ${sets.join(", ")} WHERE id=?`, - args: sqlArgs, - }); + if (args.name != null) { + await db.execute({ + sql: `UPDATE research_projects SET name=?, updated_at=datetime('now') WHERE id=?`, + args: [args.name, learner_id], + }); + } - // Upsert settings row - const settingsCols = ["transcripts_enabled", "transcripts_retention_days", "idle_lock_default_min", "auto_resume_min", "voice_input_enabled"]; + // Upsert settings row (includes age, avatar, and per-learner flags). + const settingsCols = ["age", "avatar", "transcripts_enabled", "transcripts_retention_days", "idle_lock_default_min", "auto_resume_min", "voice_input_enabled"]; const updates = []; const updArgs = []; for (const c of settingsCols) { @@ -323,40 +296,23 @@ export function createMakerLabServer(db, options = {}) { batch_id: z.string().max(64).optional(), }, async ({ learner_id, duration_min = SESSION_DEFAULT_MIN, idle_lock_min, batch_id }) => { - const r = await db.execute({ - sql: `SELECT rp.id, rp.name, mls.transcripts_enabled, mls.idle_lock_default_min - FROM research_projects rp - LEFT JOIN maker_learner_settings mls ON mls.learner_id=rp.id - WHERE rp.id=? AND rp.type='learner_profile'`, - args: [learner_id], - }); - if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`); - const learner = r.rows[0]; - const token = mintToken(); - const code = mintRedemptionCode(); - const expiresAt = addMinutesISO(duration_min); - const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); - const idleMin = idle_lock_min ?? learner.idle_lock_default_min ?? null; - - await db.execute({ - sql: `INSERT INTO maker_sessions - (token, learner_id, is_guest, expires_at, idle_lock_min, transcripts_enabled_snapshot, batch_id) - VALUES (?, ?, 0, ?, ?, ?, ?)`, - args: [token, learner_id, expiresAt, idleMin, learner.transcripts_enabled ? 1 : 0, batch_id || null], - }); - await db.execute({ - sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, - args: [code, token, codeExpiresAt], - }); - return mcpOk({ - redemption_code: code, - short_url: `/kiosk/r/${code}`, - code_expires_at: codeExpiresAt, - session_expires_at: expiresAt, - learner_id, - learner_name: learner.name, - batch_id: batch_id || null, - }); + try { + const r = await mintSessionForLearner(db, { + learnerId: learner_id, durationMin: duration_min, + idleLockMin: idle_lock_min, batchId: batch_id || null, + }); + return mcpOk({ + redemption_code: r.redemptionCode, + short_url: r.shortUrl, + code_expires_at: r.codeExpiresAt, + session_expires_at: r.sessionExpiresAt, + learner_id: r.learnerId, + learner_name: r.learnerName, + batch_id: r.batchId, + }); + } catch (err) { + return mcpError(err.message); + } } ); @@ -370,47 +326,20 @@ export function createMakerLabServer(db, options = {}) { batch_label: z.string().max(200).optional(), }, async ({ learner_ids, duration_min = SESSION_DEFAULT_MIN, idle_lock_min, batch_label }) => { - const batchId = randomUUID(); - await db.execute({ - sql: `INSERT INTO maker_batches (batch_id, label) VALUES (?, ?)`, - args: [batchId, batch_label || null], + const { batchId, sessions, errors } = await mintBatchSessions(db, { + learnerIds: learner_ids, durationMin: duration_min, + idleLockMin: idle_lock_min, batchLabel: batch_label, + }); + return mcpOk({ + batch_id: batchId, + batch_label: batch_label || null, + sessions: sessions.map((r) => ({ + learner_id: r.learnerId, learner_name: r.learnerName, + redemption_code: r.redemptionCode, short_url: r.shortUrl, + code_expires_at: r.codeExpiresAt, session_expires_at: r.sessionExpiresAt, + })), + errors, }); - const sessions = []; - for (const lid of learner_ids) { - const r = await db.execute({ - sql: `SELECT rp.id, rp.name, mls.transcripts_enabled, mls.idle_lock_default_min - FROM research_projects rp - LEFT JOIN maker_learner_settings mls ON mls.learner_id=rp.id - WHERE rp.id=? AND rp.type='learner_profile'`, - args: [lid], - }); - if (!r.rows.length) { - sessions.push({ learner_id: lid, error: "not_found" }); - continue; - } - const learner = r.rows[0]; - const token = mintToken(); - const code = mintRedemptionCode(); - const expiresAt = addMinutesISO(duration_min); - const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); - const idleMin = idle_lock_min ?? learner.idle_lock_default_min ?? null; - await db.execute({ - sql: `INSERT INTO maker_sessions - (token, learner_id, is_guest, expires_at, idle_lock_min, transcripts_enabled_snapshot, batch_id) - VALUES (?, ?, 0, ?, ?, ?, ?)`, - args: [token, lid, expiresAt, idleMin, learner.transcripts_enabled ? 1 : 0, batchId], - }); - await db.execute({ - sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, - args: [code, token, codeExpiresAt], - }); - sessions.push({ - learner_id: lid, learner_name: learner.name, - redemption_code: code, short_url: `/kiosk/r/${code}`, - code_expires_at: codeExpiresAt, session_expires_at: expiresAt, - }); - } - return mcpOk({ batch_id: batchId, batch_label: batch_label || null, sessions }); } ); @@ -421,25 +350,12 @@ export function createMakerLabServer(db, options = {}) { age_band: z.enum(["5-9", "10-13", "14+"]), }, async ({ age_band }) => { - const token = mintToken(); - const code = mintRedemptionCode(); - const expiresAt = addMinutesISO(GUEST_MAX_MIN); - const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); - await db.execute({ - sql: `INSERT INTO maker_sessions - (token, learner_id, is_guest, guest_age_band, expires_at, transcripts_enabled_snapshot) - VALUES (?, NULL, 1, ?, ?, 0)`, - args: [token, age_band, expiresAt], - }); - await db.execute({ - sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, - args: [code, token, codeExpiresAt], - }); + const r = await mintGuestSession(db, { ageBand: age_band }); return mcpOk({ - redemption_code: code, - short_url: `/kiosk/r/${code}`, + redemption_code: r.redemptionCode, + short_url: r.shortUrl, persona: ageBandFromGuestBand(age_band), - session_expires_at: expiresAt, + session_expires_at: r.sessionExpiresAt, is_guest: true, }); } diff --git a/bundles/maker-lab/server/sessions.js b/bundles/maker-lab/server/sessions.js new file mode 100644 index 0000000..1ddbddf --- /dev/null +++ b/bundles/maker-lab/server/sessions.js @@ -0,0 +1,128 @@ +/** + * Maker Lab — session minting helpers. + * + * Single source of truth for creating sessions + redemption codes. + * Used by: + * - server.js MCP tools (maker_start_session, maker_start_sessions_bulk, + * maker_start_guest_session) + * - panel/maker-lab.js (admin panel Start-session button) + * + * Centralized so the code/token minting format, expiry math, and + * snapshot-at-start contract for transcripts live in one place. + */ + +import { randomBytes, randomUUID } from "node:crypto"; + +export const SESSION_DEFAULT_MIN = 60; +export const SESSION_MAX_MIN = 240; +export const GUEST_MAX_MIN = 30; +export const CODE_TTL_MIN = 10; + +export function mintToken() { + return randomBytes(24).toString("base64url"); +} + +export function mintRedemptionCode() { + const A = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O + const N = "23456789"; // no 0, 1 + const pick = (s) => s[Math.floor(Math.random() * s.length)]; + return `${pick(A)}${pick(A)}${pick(A)}-${pick(N)}${pick(N)}${pick(N)}`; +} + +export function addMinutesISO(min) { + return new Date(Date.now() + min * 60_000).toISOString(); +} + +/** + * Mint a session for a learner + insert a one-shot redemption code. + * Returns { sessionToken, redemptionCode, codeExpiresAt, sessionExpiresAt, + * learnerName, batchId }. + */ +export async function mintSessionForLearner(db, { learnerId, durationMin = SESSION_DEFAULT_MIN, idleLockMin, batchId = null }) { + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, mls.transcripts_enabled, mls.idle_lock_default_min + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id + WHERE rp.id = ? AND rp.type = 'learner_profile'`, + args: [learnerId], + }); + if (!r.rows.length) { + const err = new Error(`Learner ${learnerId} not found`); + err.code = "learner_not_found"; + throw err; + } + const learner = r.rows[0]; + const sessionToken = mintToken(); + const redemptionCode = mintRedemptionCode(); + const sessionExpiresAt = addMinutesISO(Math.min(durationMin, SESSION_MAX_MIN)); + const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); + const idleMin = idleLockMin ?? learner.idle_lock_default_min ?? null; + + await db.execute({ + sql: `INSERT INTO maker_sessions + (token, learner_id, is_guest, expires_at, idle_lock_min, transcripts_enabled_snapshot, batch_id) + VALUES (?, ?, 0, ?, ?, ?, ?)`, + args: [sessionToken, learnerId, sessionExpiresAt, idleMin, learner.transcripts_enabled ? 1 : 0, batchId], + }); + await db.execute({ + sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, + args: [redemptionCode, sessionToken, codeExpiresAt], + }); + + return { + sessionToken, redemptionCode, codeExpiresAt, sessionExpiresAt, + learnerId, learnerName: learner.name, batchId, + shortUrl: `/kiosk/r/${redemptionCode}`, + }; +} + +/** + * Mint an ephemeral guest session. No learner profile; no memories; + * no transcripts; no artifact persistence. 30-min cap. + */ +export async function mintGuestSession(db, { ageBand }) { + const sessionToken = mintToken(); + const redemptionCode = mintRedemptionCode(); + const sessionExpiresAt = addMinutesISO(GUEST_MAX_MIN); + const codeExpiresAt = addMinutesISO(CODE_TTL_MIN); + + await db.execute({ + sql: `INSERT INTO maker_sessions + (token, learner_id, is_guest, guest_age_band, expires_at, transcripts_enabled_snapshot) + VALUES (?, NULL, 1, ?, ?, 0)`, + args: [sessionToken, ageBand, sessionExpiresAt], + }); + await db.execute({ + sql: `INSERT INTO maker_redemption_codes (code, session_token, expires_at) VALUES (?, ?, ?)`, + args: [redemptionCode, sessionToken, codeExpiresAt], + }); + + return { + sessionToken, redemptionCode, codeExpiresAt, sessionExpiresAt, + ageBand, isGuest: true, + shortUrl: `/kiosk/r/${redemptionCode}`, + }; +} + +/** + * Create a batch + mint sessions for an array of learner ids sharing + * the same batch_id. Returns { batchId, sessions: [...], errors: [...] }. + */ +export async function mintBatchSessions(db, { learnerIds, durationMin, idleLockMin, batchLabel }) { + const batchId = randomUUID(); + await db.execute({ + sql: `INSERT INTO maker_batches (batch_id, label) VALUES (?, ?)`, + args: [batchId, batchLabel || null], + }); + const sessions = []; + const errors = []; + for (const lid of learnerIds) { + try { + const r = await mintSessionForLearner(db, { learnerId: lid, durationMin, idleLockMin, batchId }); + sessions.push(r); + } catch (err) { + errors.push({ learner_id: lid, error: err.code || "error", message: err.message }); + } + } + return { batchId, batchLabel: batchLabel || null, sessions, errors }; +} From d75b785275158fe7babc246da368204e584274f1 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:03:32 -0500 Subject: [PATCH 5/9] Maker Lab: Phase 2.2 per-learner settings, idle lock, transcripts, DATA-HANDLING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-learner settings editor (panel/maker-lab.js): - New ?edit= view with form for name, age, avatar, transcripts_enabled, transcripts_retention_days, idle_lock_default_min, auto_resume_min, voice_input_enabled. - New action=update_learner POST handler. - "Settings" and "Transcripts" buttons on every learner card (Transcripts only when recording is enabled). Kiosk idle lock (panel/routes.js + public/blockly/tutor-bridge.js): - /api/context is now PASSIVE (read-only) — no longer touches last_activity_at. That would have made idle-lock impossible since the client polls it. - New /api/heartbeat endpoint — the only client-initiated path besides /api/hint and /api/progress that counts as activity (per plan's allowlist: hint, progress, Blockly workspace change, heartbeat — NOT mouse-move or scroll). - Idle-lock state machine runs inline on every /api/context hit: (1) lock when last_activity > idle_lock_min ago; (2) auto-resume when locked > auto_resume_min ago. - tutor-bridge.js: 15s context poll, lock screen with auto-resume countdown (built with createElement, no innerHTML), throttled heartbeat on Blockly workspace changes (create/delete/change/move only — ignores UI events). Transcripts viewer (panel/maker-lab.js): - ?transcripts= view groups turns by session_token, shows role-coded turn bubbles, retention banner. - Up to 500 most-recent turns across all sessions for one learner. Transcripts retention sweep (server/retention-sweep.js): - Hourly sweep deletes maker_transcripts older than per-learner transcripts_retention_days (default 30, 0 = purge on session end). - Also sweeps orphaned guest sessions hourly (belt + suspenders on top of the boot-time sweep). - Timer uses unref() so the process can still exit cleanly when stdin closes. - startRetentionSweep() is process-globally idempotent; both the stdio MCP entry and the panel router call it. DATA-HANDLING.md (bundles/maker-lab/DATA-HANDLING.md): - Ships with the bundle. Plain-language summary for parents/teachers + legal-reference section for school administrators. - Exhaustive field inventory across every table, COPPA + GDPR-K posture, incident response procedure, deployment checklist. - Maker Lab's consent checkbox is a timestamped audit record — explicitly NOT a substitute for the school's own VPC process. The doc says so. --- bundles/maker-lab/DATA-HANDLING.md | 133 +++++++++ bundles/maker-lab/panel/maker-lab.js | 269 ++++++++++++++++++ bundles/maker-lab/panel/routes.js | 105 ++++++- bundles/maker-lab/public/blockly/kiosk.css | 13 + .../maker-lab/public/blockly/tutor-bridge.js | 92 +++++- bundles/maker-lab/server/index.js | 2 + bundles/maker-lab/server/retention-sweep.js | 72 +++++ 7 files changed, 676 insertions(+), 10 deletions(-) create mode 100644 bundles/maker-lab/DATA-HANDLING.md create mode 100644 bundles/maker-lab/server/retention-sweep.js diff --git a/bundles/maker-lab/DATA-HANDLING.md b/bundles/maker-lab/DATA-HANDLING.md new file mode 100644 index 0000000..1a84809 --- /dev/null +++ b/bundles/maker-lab/DATA-HANDLING.md @@ -0,0 +1,133 @@ +# Maker Lab — Data Handling + +This document tells you exactly what data Maker Lab stores, where it lives, who can access it, how long it sticks around, and how to get rid of it. It has two halves: a plain-language summary for parents and teachers, and a legal-reference section for school administrators reviewing deployment. + +Maker Lab is a Crow bundle. Nothing in this document is a cloud service — everything runs on the Crow host you installed it on. + +--- + +## Part 1 — Plain-language summary (for parents and teachers) + +### What we store + +When you create a learner profile: + +- **Name** (first name or nickname) +- **Age** (used to pick the tutor persona: kid / tween / adult) +- **Avatar** (Live2D character id — just a reference, e.g. `mao_pro`) +- **Consent timestamp** (when you checked the consent box) + +When a learner uses the kiosk: + +- **Progress events** — "started lesson X", "completed lesson Y". Timestamped. +- **Artifacts** — any Blockly workspaces the kid saves. These live in Crow's storage. +- **Session records** — when a session started and ended, how many hints were used. No conversation content unless transcripts are turned on. + +Optionally (opt-in, per learner): + +- **Conversation transcripts** — the kid↔tutor chat text. **Off by default.** You turn this on in the learner's Settings page ("Record conversation transcripts for this learner"). + +### What we never store + +- Email addresses, phone numbers, home addresses +- Photos or video of the kid +- Browser fingerprints tied to the kid's identity (kiosk device fingerprints are tied to the kiosk *device*, not the learner) +- Third-party analytics or telemetry. Nothing phones home. +- Contents of mic input (mic is off by default; even when on, audio is not recorded) + +### Where it lives + +Everything is in your Crow's local SQLite database (`~/.crow/data/crow.db`) and Crow's local storage bucket. It does not leave your host. + +### How long it sticks around + +- Profile data, progress events, artifacts: **forever**, until you delete the learner profile. +- Sessions: keeps the record; the session token itself is revoked on session end. +- Transcripts: **default 30 days**, configurable per learner (0 = purge on session end). A background sweep runs hourly. +- Redemption codes: expire in 10 minutes and are one-shot. + +### Who can see it + +- The **admin** (whoever has the Crow's Nest password) can see everything via the Maker Lab panel: list learners, read transcripts, export, delete. +- The **kid on the kiosk** can't see other learners' data. The session token scopes all read paths. +- **No one else.** There is no "share this learner's progress" feature. Peer-sharing tools (`crow_share`, `crow_send_message`) refuse to run while any Maker Lab session is active. + +### How to get a copy of the data (data-subject access) + +From the admin AI chat or the MCP tool: + +> `maker_export_learner(learner_id)` → returns a JSON bundle with everything: profile, sessions, transcripts (if enabled), progress memories, artifact references. + +### How to delete the data (right to be forgotten) + +> `maker_delete_learner(learner_id, confirm: "DELETE", reason: "...")` + +This cascades across every table: `maker_sessions`, `maker_transcripts`, `maker_bound_devices`, `maker_learner_settings`, `memories WHERE project_id = `, and `research_projects`. The panel has a two-step confirm (Tier 1). + +### If you stop using Maker Lab + +Uninstall the bundle from the Extensions page. The DB tables stay; run `maker_delete_learner` for each learner first, or drop the tables manually (`DROP TABLE maker_sessions; ...`). The Crow DB is yours — we don't do anything irreversible on uninstall. + +--- + +## Part 2 — Legal reference (for school administrators) + +Maker Lab is self-hosted. **Self-hosting does not exempt a US school from COPPA or an EU school from GDPR-K.** The consent process is the school's (or the family's) responsibility. What Maker Lab provides is the infrastructure to support that process: an audit trail, an export path, and a right-to-be-forgotten path. + +### Fields stored (exhaustive) + +| Table | Field | Notes | +|---|---|---| +| `research_projects` (type='learner_profile') | id, name, description, created_at, updated_at | Crow's shared project table; learner profiles are isolated from the generic Projects panel and `crow_recall_by_context` defaults (see the Phase 1 audit patches). | +| `maker_learner_settings` | learner_id, age, avatar, transcripts_enabled, transcripts_retention_days, idle_lock_default_min, auto_resume_min, voice_input_enabled, consent_captured_at, updated_at | Per-learner settings + consent timestamp. | +| `maker_sessions` | token, learner_id, is_guest, guest_age_band, batch_id, started_at, expires_at, revoked_at, state, idle_lock_min, idle_locked_at, last_activity_at, kiosk_device_id, hints_used, transcripts_enabled_snapshot | Session records. Token is opaque (24-byte random). | +| `maker_redemption_codes` | code, session_token, expires_at, used_at, claimed_by_fingerprint, created_at | One-shot handoff codes; 10-min TTL. `claimed_by_fingerprint` is a SHA-256 of `UA + Accept-Language + per-device localStorage salt`. | +| `maker_bound_devices` | fingerprint, learner_id, label, bound_at, last_seen_at | Solo-mode LAN-bound device registry. Empty unless the admin opts in to LAN exposure. | +| `maker_batches` | batch_id, label, created_by_admin, created_at, revoked_at, revoke_reason | Classroom batch metadata. | +| `maker_transcripts` | id, learner_id, session_token, turn_no, role ('kid'/'tutor'/'system'), content, created_at | Only written when `transcripts_enabled_snapshot = 1` on the session. | +| `memories` (source='maker-lab') | content, context, tags, source, importance, category, created_at, updated_at, accessed_at, access_count, project_id | Progress events. Tagged with `source='maker-lab'` so generic memory recall (`crow_recall_by_context`) excludes them by default. | + +### Security posture + +- **Kiosk auth**: per-session HttpOnly, SameSite=Strict, `__Host-`-prefixed cookie signed with HMAC-SHA256. Device fingerprint verified on every `/kiosk/*` request; a stolen cookie on a different device fails the fingerprint check. +- **Redemption**: atomic `UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING`. Exactly one racing redemption wins; all others get HTTP 410. +- **Cookie secret**: persists at `~/.crow/maker-lab.cookie.secret`. Rotating it invalidates all kiosks (force re-bind). +- **No URL-carried tokens**: URLs carry a short redemption code, never the session token. Browser history, projector mirrors, DHCP logs don't leak credentials. +- **LLM output filter**: every `maker_hint` return passes a Flesch-Kincaid grade cap (kid-tutor only), a kid-safe blocklist, and a per-persona word budget before reaching TTS. Prompt-only safety is not adequate for 5-year-olds. +- **Peer-sharing lockdown**: `crow_share`, `crow_generate_invite`, `crow_send_message` refuse to run while any Maker Lab session is active. + +### COPPA (US) + +- Operators covered by COPPA must obtain verifiable parental consent before collecting personal information from children under 13. +- Maker Lab's consent checkbox is a timestamped audit record — it is **not** a substitute for the school's own verifiable-parental-consent process. The checkbox text reads: *"I am the parent/guardian of this child, or I am the child's teacher operating under the school's consent process."* +- Data-subject access: `maker_export_learner`. Deletion: `maker_delete_learner`. +- Data minimization: only name, age, avatar, consent timestamp, and (if enabled) transcripts + progress. No PII beyond those fields is collected from the child. +- No third-party data sharing. No advertising. No analytics. + +### GDPR-K (EU, children under 16; member-state minimums vary) + +- Maker Lab provides the technical means to support Articles 15 (access), 17 (erasure), and 20 (portability): `maker_export_learner` returns a JSON bundle. +- Lawful basis is **not** inferred from the checkbox; the deploying institution must establish it (typically consent or legitimate interest backed by DPIA). +- Retention defaults: transcripts 30 days, everything else indefinite until deletion — configurable per learner. Schools with stricter retention requirements should set `transcripts_retention_days` to match their policy. +- No data transfers. Everything stays on the Crow host. + +### Incident response + +- If a learner profile is accidentally exposed (e.g. a leaked database backup), the admin should: + 1. Rotate `~/.crow/maker-lab.cookie.secret` — invalidates every active kiosk. + 2. Rotate any API keys in Crow's AI Profiles. + 3. Export and delete the affected learners per the institution's breach-notification policy. +- No automated breach notification. The self-hosted posture means the school is the controller for both purposes and incident reporting. + +### Deploying in a school + +Recommended checklist before first classroom use: + +1. Run Maker Lab on a school-managed host (not a teacher's personal device). +2. Confirm the school's VPC/LAN doesn't expose the Crow host's `/kiosk/*` routes to the internet. +3. Decide whether transcripts are on or off per learner, and document it in the school's records policy. +4. Decide a retention period that matches the school's records retention policy; set `transcripts_retention_days` accordingly. +5. Complete the school's standard parental consent process (COPPA in the US, GDPR-K / member-state analogue in the EU) **before** using the consent checkbox in the panel. +6. Name a data-protection point of contact (teacher, IT admin, or DPO in EU) and document how requests (export / delete) are handled. + +Questions, corrections, and legal review welcome. This document reflects the bundle's behavior as of the most recent Phase 2.2 commit; behavior can be verified by reading the SQL in `bundles/maker-lab/server/init-tables.js` and the route code in `bundles/maker-lab/panel/routes.js`. diff --git a/bundles/maker-lab/panel/maker-lab.js b/bundles/maker-lab/panel/maker-lab.js index 143be0b..8a98749 100644 --- a/bundles/maker-lab/panel/maker-lab.js +++ b/bundles/maker-lab/panel/maker-lab.js @@ -214,6 +214,38 @@ export default { return res.redirect("/dashboard/maker-lab"); } + if (a === "update_learner") { + const lid = Number(req.body.learner_id); + if (!Number.isFinite(lid)) return res.redirect("/dashboard/maker-lab?err=learner_not_found"); + const name = String(req.body.name || "").trim().slice(0, 100); + const age = Number(req.body.age); + const avatar = String(req.body.avatar || "").slice(0, 50) || null; + if (!name || !Number.isFinite(age) || age < 3 || age > 100) { + return res.redirect(`/dashboard/maker-lab?edit=${lid}&err=create_invalid`); + } + await db.execute({ + sql: `UPDATE research_projects SET name=?, updated_at=datetime('now') + WHERE id=? AND type='learner_profile'`, + args: [name, lid], + }); + const transcripts = req.body.transcripts_enabled === "1" ? 1 : 0; + const retention = Math.max(0, Math.min(3650, Number(req.body.transcripts_retention_days) || 30)); + const idleMin = req.body.idle_lock_default_min === "" ? null + : Math.max(0, Math.min(240, Number(req.body.idle_lock_default_min) || 0)); + const autoResume = Math.max(0, Math.min(240, Number(req.body.auto_resume_min) || 15)); + const voice = req.body.voice_input_enabled === "1" ? 1 : 0; + await db.execute({ + sql: `UPDATE maker_learner_settings SET + age = ?, avatar = ?, + transcripts_enabled = ?, transcripts_retention_days = ?, + idle_lock_default_min = ?, auto_resume_min = ?, + voice_input_enabled = ?, updated_at = datetime('now') + WHERE learner_id = ?`, + args: [age, avatar, transcripts, retention, idleMin, autoResume, voice, lid], + }); + return res.redirect(`/dashboard/maker-lab?edit=${lid}&saved=1`); + } + if (a === "unlock_idle") { const token = String(req.body.session_token || ""); if (token) { @@ -270,6 +302,62 @@ export default { }); } + // Per-learner edit view + if (req.query.edit) { + const lid = Number(req.query.edit); + if (!Number.isFinite(lid)) { + return layout({ title: "Not found", content: `Back` }); + } + const r = await db.execute({ + sql: `SELECT rp.id, rp.name, rp.created_at, mls.* + FROM research_projects rp + LEFT JOIN maker_learner_settings mls ON mls.learner_id = rp.id + WHERE rp.id = ? AND rp.type = 'learner_profile'`, + args: [lid], + }); + if (!r.rows.length) { + return layout({ title: "Not found", content: `

Learner not found.

Back` }); + } + const saved = req.query.saved === "1"; + const errKey = String(req.query.err || ""); + return layout({ + title: `Edit ${r.rows[0].name}`, + content: renderEditView({ learner: r.rows[0], saved, errKey, escapeHtml }), + }); + } + + // Transcripts view + if (req.query.transcripts) { + const lid = Number(req.query.transcripts); + if (!Number.isFinite(lid)) { + return layout({ title: "Not found", content: `Back` }); + } + const [learnerR, settingsR, transcriptsR] = await Promise.all([ + db.execute({ sql: "SELECT id, name FROM research_projects WHERE id=? AND type='learner_profile'", args: [lid] }), + db.execute({ sql: "SELECT * FROM maker_learner_settings WHERE learner_id=?", args: [lid] }), + db.execute({ + sql: `SELECT id, session_token, turn_no, role, content, created_at + FROM maker_transcripts + WHERE learner_id = ? + ORDER BY created_at DESC, turn_no DESC + LIMIT 500`, + args: [lid], + }), + ]); + if (!learnerR.rows.length) { + return layout({ title: "Not found", content: `Back` }); + } + return layout({ + title: `Transcripts — ${learnerR.rows[0].name}`, + content: renderTranscriptsView({ + learner: learnerR.rows[0], + settings: settingsR.rows[0] || {}, + transcripts: transcriptsR.rows, + escapeHtml, + }), + }); + } + // Batch sheet view (printable) if (req.query.batch) { const batchId = String(req.query.batch).slice(0, 64); @@ -553,6 +641,8 @@ function renderMainView({ mode, err, pendingDelete, showGuestPicker, learners, a `} + Settings + ${l.transcripts_enabled ? `Transcripts` : ''} ${isPending ? `
@@ -701,3 +791,182 @@ function css() { .session-list li .meta { color: var(--muted, #888); font-size: 0.8em; margin-left: auto; } `; } + +// ─── Render: per-learner edit view ──────────────────────────────────────── + +function renderEditView({ learner, saved, errKey, escapeHtml }) { + const persona = learner.age == null ? "kid-tutor" + : learner.age <= 9 ? "kid-tutor" + : learner.age <= 13 ? "tween-tutor" + : "adult-tutor"; + const errMsg = errKey === "create_invalid" ? "Name is required and age must be 3-100." : ""; + const savedBanner = saved ? `` : ""; + const errBanner = errMsg ? `` : ""; + return ` + +
+
+ ← Back +

Settings — ${escapeHtml(learner.name)}

+ ${persona} +
+ ${savedBanner}${errBanner} + + + + + +
+ + +
+
+ + +
Drives persona: ≤9 = kid-tutor, 10-13 = tween-tutor, 14+ = adult-tutor.
+
+
+ + +
Live2D model id used by the companion.
+
+ +

Privacy

+
+ +
Off by default. When on, each kid-tutor turn is stored for review. Flag is read at session start and frozen for the session's lifetime — changes take effect next session.
+
+
+ + +
0 = purge on session end.
+
+ +

Session behavior

+
+ + +
Lock the kiosk after this many minutes with no tutor/lesson activity. The session token stays valid.
+
+
+ + +
0 = never auto-resume (admin must manually unlock).
+
+
+ +
Caution: voice input is NOT filtered by the hint output guard. Appropriate for older kids only.
+
+ +
+ + Cancel +
+ +
+ `; +} + +// ─── Render: transcripts view ───────────────────────────────────────────── + +function renderTranscriptsView({ learner, settings, transcripts, escapeHtml }) { + const retention = settings.transcripts_retention_days ?? 30; + const enabled = !!settings.transcripts_enabled; + + // Group by session_token + const sessions = new Map(); + for (const t of transcripts) { + if (!sessions.has(t.session_token)) sessions.set(t.session_token, []); + sessions.get(t.session_token).push(t); + } + + // Reverse within each session so turns are in chronological order + for (const [k, arr] of sessions) { + arr.sort((a, b) => a.turn_no - b.turn_no); + } + + const sessionBlocks = [...sessions.entries()].map(([token, turns]) => { + const first = turns[0]; + const last = turns[turns.length - 1]; + return ` +
+ + Session ${escapeHtml(token.slice(0, 8))}… + ${escapeHtml(first.created_at)} — ${escapeHtml(last.created_at)} · ${turns.length} turns + +
+ ${turns.map((t) => ` +
+ ${t.role} +
${escapeHtml(t.content)}
+ #${t.turn_no} · ${escapeHtml(t.created_at)} +
+ `).join("")} +
+
+ `; + }).join(""); + + return ` + +
+
+ ← Back +

Transcripts — ${escapeHtml(learner.name)}

+ ${enabled ? 'recording on' : 'recording off'} + Settings +
+ + ${sessionBlocks || '

(nothing to show)

'} +
+ `; +} diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js index 942660e..772c3d8 100644 --- a/bundles/maker-lab/panel/routes.js +++ b/bundles/maker-lab/panel/routes.js @@ -158,12 +158,25 @@ function ageBandFromGuestBand(band) { return "adult-tutor"; } +// Lazy-import the retention sweep so the panel router still loads on installs +// where the bundle's server/ modules aren't reachable. +let startRetentionSweep; +try { + const mod = await import(pathToFileURL(resolve(__dirname, "../server/retention-sweep.js")).href); + startRetentionSweep = mod.startRetentionSweep; +} catch { + startRetentionSweep = null; +} + export default function makerLabKioskRouter(/* dashboardAuth */) { const router = Router(); let db; router.use((req, res, next) => { - if (!db && createDbClient) db = createDbClient(); + if (!db && createDbClient) { + db = createDbClient(); + if (db && startRetentionSweep) startRetentionSweep(db); + } if (!db) return res.status(500).json({ error: "db_unavailable" }); next(); }); @@ -251,11 +264,72 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { const s = guard.session; const age = typeof s.learner_age === "number" ? s.learner_age : null; const persona = s.is_guest ? ageBandFromGuestBand(s.guest_age_band) : personaForAge(age); - // Activity touch - await db.execute({ - sql: `UPDATE maker_sessions SET last_activity_at = datetime('now'), idle_locked_at = NULL WHERE token = ?`, - args: [guard.sessionToken], - }); + + // /api/context is PASSIVE (read-only). It does NOT touch last_activity_at — + // that would make idle-lock impossible since the client polls this endpoint. + // Activity is written by /api/hint, /api/progress, /api/heartbeat only. + + // Inline idle-lock state machine: + // 1) If idle_lock_min set AND no lock yet AND last_activity > idle_lock_min ago + // → set idle_locked_at = now + // 2) If locked AND auto_resume_min > 0 AND locked > auto_resume_min ago + // → clear idle_locked_at, reset last_activity_at (auto-resume) + let idleLocked = !!s.idle_locked_at; + let autoResumeEta = null; + + if (s.idle_lock_min && !s.idle_locked_at) { + // Check if we should lock. + const check = await db.execute({ + sql: `SELECT token, idle_lock_min, + (julianday('now') - julianday(last_activity_at)) * 1440 AS minutes_idle + FROM maker_sessions WHERE token = ?`, + args: [guard.sessionToken], + }); + const row = check.rows[0]; + if (row && row.minutes_idle >= row.idle_lock_min) { + await db.execute({ + sql: `UPDATE maker_sessions SET idle_locked_at = datetime('now') WHERE token = ? AND idle_locked_at IS NULL`, + args: [guard.sessionToken], + }); + idleLocked = true; + } + } + + if (s.idle_locked_at) { + // Get effective auto_resume_min from learner settings (non-guest only). + let autoMin = 15; + if (!s.is_guest && s.learner_id != null) { + try { + const settingsR = await db.execute({ + sql: `SELECT auto_resume_min FROM maker_learner_settings WHERE learner_id = ?`, + args: [s.learner_id], + }); + if (settingsR.rows.length && settingsR.rows[0].auto_resume_min != null) { + autoMin = settingsR.rows[0].auto_resume_min; + } + } catch {} + } + if (autoMin > 0) { + const r = await db.execute({ + sql: `SELECT (julianday('now') - julianday(idle_locked_at)) * 1440 AS mins_locked + FROM maker_sessions WHERE token = ?`, + args: [guard.sessionToken], + }); + const minsLocked = r.rows[0]?.mins_locked || 0; + if (minsLocked >= autoMin) { + // Auto-resume: clear lock AND reset activity so we don't instantly re-lock. + await db.execute({ + sql: `UPDATE maker_sessions SET idle_locked_at = NULL, last_activity_at = datetime('now') + WHERE token = ?`, + args: [guard.sessionToken], + }); + idleLocked = false; + } else { + autoResumeEta = Math.max(0, (autoMin - minsLocked) * 60); // seconds remaining + } + } + } + res.json({ persona, state: s.state, @@ -263,10 +337,29 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { hints_used: s.hints_used, expires_at: s.expires_at, idle_lock_min: s.idle_lock_min, + idle_locked: idleLocked, + auto_resume_eta_seconds: autoResumeEta, transcripts_on: !!s.transcripts_enabled_snapshot, }); }); + // ─── /kiosk/api/heartbeat ────────────────────────────────────────────── + // Activity touch. Called by tutor-bridge.js on Blockly workspace change + // events AND by the "I'm here" button if we add one. This is the only + // client-initiated path that counts as activity besides hint/progress POSTs. + + router.post("/kiosk/api/heartbeat", express_json(), async (req, res) => { + const guard = await requireKioskSession(req, db); + if (!guard.ok) return res.status(401).json({ error: guard.reason }); + if (guard.session.state === "revoked") return res.status(410).json({ error: "revoked" }); + await db.execute({ + sql: `UPDATE maker_sessions SET last_activity_at = datetime('now'), idle_locked_at = NULL + WHERE token = ?`, + args: [guard.sessionToken], + }); + res.json({ ok: true }); + }); + // ─── /kiosk/api/lesson/:id ───────────────────────────────────────────── router.get("/kiosk/api/lesson/:id", async (req, res) => { diff --git a/bundles/maker-lab/public/blockly/kiosk.css b/bundles/maker-lab/public/blockly/kiosk.css index b803892..8686864 100644 --- a/bundles/maker-lab/public/blockly/kiosk.css +++ b/bundles/maker-lab/public/blockly/kiosk.css @@ -52,3 +52,16 @@ html, body { margin: 0; height: 100%; font-family: system-ui, -apple-system, "Se background: rgba(239,68,68,0.1); color: var(--danger); font-size: 0.8rem; z-index: 15; } + +.lock-screen { + position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; z-index: 100; +} +.lock-box { + background: var(--card, #fff); color: var(--fg, #222); + padding: 2rem 3rem; border-radius: 16px; + text-align: center; max-width: 420px; box-shadow: 0 20px 50px rgba(0,0,0,0.3); +} +.lock-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem; } +.lock-hint { color: var(--muted, #6b7280); margin-bottom: 1rem; } +.lock-countdown { font-size: 1.1rem; font-variant-numeric: tabular-nums; color: var(--accent-2, #3b82f6); } diff --git a/bundles/maker-lab/public/blockly/tutor-bridge.js b/bundles/maker-lab/public/blockly/tutor-bridge.js index 3389b87..bc42de3 100644 --- a/bundles/maker-lab/public/blockly/tutor-bridge.js +++ b/bundles/maker-lab/public/blockly/tutor-bridge.js @@ -181,6 +181,59 @@ window.addEventListener("offline", () => { offlineChip.hidden = false; }); +// ─── Idle lock screen ────────────────────────────────────────────────────── + +let lockEl = null; +let lockCountdownEl = null; +let resumeTimer = null; + +function buildLockScreen() { + const root = document.createElement("div"); + root.className = "lock-screen"; + const box = document.createElement("div"); + box.className = "lock-box"; + const title = document.createElement("div"); + title.className = "lock-title"; + title.textContent = "Ask a grown-up to unlock"; + const hint = document.createElement("div"); + hint.className = "lock-hint"; + hint.textContent = "We noticed you took a break."; + const countdown = document.createElement("div"); + countdown.className = "lock-countdown"; + box.appendChild(title); + box.appendChild(hint); + box.appendChild(countdown); + root.appendChild(box); + lockCountdownEl = countdown; + return root; +} + +function showLockScreen(etaSeconds) { + if (!lockEl) { + lockEl = buildLockScreen(); + document.body.appendChild(lockEl); + } + lockEl.hidden = false; + if (resumeTimer) clearInterval(resumeTimer); + let remaining = Math.max(0, Math.floor(Number(etaSeconds) || 0)); + const render = () => { + if (!lockCountdownEl) return; + if (remaining <= 0) { + lockCountdownEl.textContent = "Waking up…"; + } else { + const m = Math.floor(remaining / 60), s = remaining % 60; + lockCountdownEl.textContent = `Auto-resume in ${m}:${String(s).padStart(2, "0")}`; + } + }; + render(); + resumeTimer = setInterval(() => { remaining--; render(); }, 1000); +} + +function hideLockScreen() { + if (lockEl) lockEl.hidden = true; + if (resumeTimer) { clearInterval(resumeTimer); resumeTimer = null; } +} + // ─── Boot ────────────────────────────────────────────────────────────────── async function loadContext() { @@ -191,9 +244,28 @@ async function loadContext() { } else { transcriptChip.textContent = "This chat is private"; } + if (ctx.idle_locked) { + showLockScreen(ctx.auto_resume_eta_seconds); + } else { + hideLockScreen(); + } } catch { /* non-fatal */ } } +async function heartbeat() { + try { + await apiFetch("/kiosk/api/heartbeat", { method: "POST", body: "{}" }); + } catch { /* best-effort */ } +} + +let lastHeartbeatAt = 0; +function throttledHeartbeat() { + const now = Date.now(); + if (now - lastHeartbeatAt < 5000) return; + lastHeartbeatAt = now; + heartbeat(); +} + async function loadLesson(id) { try { const r = await apiFetch(`/kiosk/api/lesson/${encodeURIComponent(id)}`); @@ -227,11 +299,23 @@ async function init() { await loadContext(); await loadLesson(urlLesson); const ws = mountBlockly(); - // Count workspace changes as activity (allowlist, per plan). - ws?.addChangeListener(() => { - // Best-effort heartbeat: light touch via context GET. - // Real activity tracking is server-side on any /kiosk/api/* hit. + // Count workspace changes as activity (per plan's allowlist: hint request, + // progress POST, Blockly workspace change, explicit heartbeat — NOT + // mouse-move or scroll). + ws?.addChangeListener((ev) => { + // Filter out Blockly-internal UI events (clicks, viewport moves) that + // aren't structural changes. Only the BLOCK_CHANGE / BLOCK_CREATE / + // BLOCK_MOVE / BLOCK_DELETE family counts as activity. + if (!ev || !ev.type) return; + const actionable = ev.type === "create" || ev.type === "delete" || + ev.type === "change" || ev.type === "move"; + if (!actionable) return; + throttledHeartbeat(); }); + // Poll /api/context periodically so idle-lock state surfaces to the kiosk + // even when the kid is just staring. 15s cadence keeps DB churn low while + // giving a tight enough feedback loop for the countdown. + setInterval(loadContext, 15_000); // Drain any queued progress from a previous offline spell. if (navigator.onLine) { queueDrain(postProgress).catch(() => {}); } } diff --git a/bundles/maker-lab/server/index.js b/bundles/maker-lab/server/index.js index bcd5251..673b258 100644 --- a/bundles/maker-lab/server/index.js +++ b/bundles/maker-lab/server/index.js @@ -12,10 +12,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { createMakerLabServer } from "./server.js"; import { initMakerLabTables } from "./init-tables.js"; import { createDbClient } from "./db.js"; +import { startRetentionSweep } from "./retention-sweep.js"; const db = createDbClient(); await initMakerLabTables(db); +startRetentionSweep(db); const server = createMakerLabServer(db, { instructions: diff --git a/bundles/maker-lab/server/retention-sweep.js b/bundles/maker-lab/server/retention-sweep.js new file mode 100644 index 0000000..d646d34 --- /dev/null +++ b/bundles/maker-lab/server/retention-sweep.js @@ -0,0 +1,72 @@ +/** + * Maker Lab — transcript retention sweep. + * + * Periodically deletes maker_transcripts rows older than the owning + * learner's transcripts_retention_days setting (default 30). Also + * purges orphaned transcripts whose learner has been deleted (belt + + * suspenders — ON DELETE CASCADE should already handle that). + * + * Runs on bundle boot and then every hour. A process-global flag + * prevents double-start if both the stdio entry and the gateway's + * panel routes call startRetentionSweep(). + */ + +const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +let started = false; +let sweepTimer = null; + +async function runOnce(db) { + // Per-learner retention from maker_learner_settings. Default 30d when unset. + // Delete transcripts whose (created_at) is older than (retention_days) days. + // We use per-learner UPDATE-style CTE; SQLite supports DELETE ... WHERE ... IN. + try { + const r = await db.execute(` + DELETE FROM maker_transcripts + WHERE id IN ( + SELECT t.id FROM maker_transcripts t + LEFT JOIN maker_learner_settings mls ON mls.learner_id = t.learner_id + WHERE (julianday('now') - julianday(t.created_at)) * 1 + >= COALESCE(mls.transcripts_retention_days, 30) + ) + `); + if (r.rowsAffected) { + console.log(`[maker-lab] retention sweep deleted ${r.rowsAffected} transcripts`); + } + } catch (err) { + // Non-fatal — table might not exist yet on fresh installs. + if (!/no such table/i.test(err.message || "")) { + console.warn("[maker-lab] retention sweep failed:", err.message); + } + } + + // Orphaned guest session sweep — any is_guest=1 sessions that have already + // ended/expired. Boot-time sweep already handles this on init, but we re-run + // hourly to catch long-lived processes. + try { + await db.execute({ + sql: `DELETE FROM maker_sessions + WHERE is_guest = 1 + AND (state = 'revoked' OR expires_at < datetime('now'))`, + args: [], + }); + } catch { + // Non-fatal + } +} + +export function startRetentionSweep(db) { + if (started) return; + started = true; + // First run soon after boot (5s grace so the DB is warm). + const initial = setTimeout(() => { runOnce(db).catch(() => {}); }, 5000); + sweepTimer = setInterval(() => { runOnce(db).catch(() => {}); }, SWEEP_INTERVAL_MS); + // Allow the process to exit cleanly — neither timer keeps it alive. + if (initial && typeof initial.unref === "function") initial.unref(); + if (sweepTimer && typeof sweepTimer.unref === "function") sweepTimer.unref(); +} + +// Exposed for direct invocation (e.g., admin panel "Sweep now" button). +export async function sweepNow(db) { + await runOnce(db); +} From 22508b83755a427a432c97a8545d597ded470bbe Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:12:56 -0500 Subject: [PATCH 6/9] Maker Lab: Phase 2.3 solo device binding + lesson authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solo-mode kiosk security (bundles/maker-lab/server/device-binding.js): - isLoopback(req): detects same-host requests across IPv4, IPv6, and IPv4-mapped-IPv6. Checks req.ip, req.socket.remoteAddress, and req.connection.remoteAddress for defense in depth. - getSoloLanExposure() / setSoloLanExposure(): dashboard_settings key maker_lab.solo_lan_exposure. Default "off" (loopback-only). - getBoundDevice(), bindDevice(), unbindDevice(), listBoundDevices(): CRUD for the maker_bound_devices table. - hasAdminSession(req): validates the req's crow_session cookie against oauth_tokens via the existing dashboard auth helper. - ensureDefaultLearner(db): creates a "Default learner" with consent timestamp if no learners exist yet. Used by the solo auto-redeem path. Solo kiosk auto-redeem (panel/routes.js GET /kiosk/): - If a valid session cookie is present → serve Blockly. - Else in solo mode: - Loopback → auto-mint default-learner session and set cookie. - LAN exposure off → 403 "loopback-only" page. - LAN exposure on + known bound device → auto-mint + touch last_seen_at. - LAN exposure on + admin crow_session present → bind device + mint. - LAN exposure on + unknown device → "sign in to Nest first" page. - Non-solo modes continue to require a redemption code handoff. Panel Settings section (panel/maker-lab.js ?settings=1): - Solo LAN exposure toggle (auto-submit on change). - Bound devices table with Unbind button per row. - Data handling pointer to DATA-HANDLING.md. - Accessible from a new ⚙ Settings button at the top of the main view. Lesson authoring (panel/maker-lab.js ?lessons=1): - Lists bundled lessons grouped by age band. - Lists custom lessons from ~/.crow/bundles/maker-lab/curriculum/custom/. - Import form: paste JSON → validate via shared lesson-validator.js (same path the maker_validate_lesson MCP tool uses) → write to the custom dir. No restart needed; the /kiosk/api/lesson/:id route already picks up custom lessons. - Specific error messages surfaced inline on validation failure. - Delete button per custom lesson (confirm dialog). - Accessible from a new 📚 Lessons button at the top of the main view. Shared validator (server/lesson-validator.js): - Extracted from the inline maker_validate_lesson tool so both the MCP tool and the panel import flow use identical rules. Adds stricter checks: id regex, canned_hints non-empty, steps non-empty, reading_level <= 3 for age_band '5-9', length caps on prompt and canned_hints, tag array typing. --- bundles/maker-lab/panel/maker-lab.js | 356 +++++++++++++++++++ bundles/maker-lab/panel/routes.js | 108 +++++- bundles/maker-lab/server/device-binding.js | 173 +++++++++ bundles/maker-lab/server/lesson-validator.js | 102 ++++++ bundles/maker-lab/server/server.js | 32 +- 5 files changed, 735 insertions(+), 36 deletions(-) create mode 100644 bundles/maker-lab/server/device-binding.js create mode 100644 bundles/maker-lab/server/lesson-validator.js diff --git a/bundles/maker-lab/panel/maker-lab.js b/bundles/maker-lab/panel/maker-lab.js index 8a98749..a66579f 100644 --- a/bundles/maker-lab/panel/maker-lab.js +++ b/bundles/maker-lab/panel/maker-lab.js @@ -24,6 +24,10 @@ import QRCode from "qrcode"; const __dirname = dirname(fileURLToPath(import.meta.url)); +async function loadDeviceBinding() { + return import(pathToFileURL(resolve(__dirname, "../server/device-binding.js")).href); +} + export default { id: "maker-lab", name: "Maker Lab", @@ -257,6 +261,68 @@ export default { return res.redirect("/dashboard/maker-lab"); } + if (a === "set_solo_lan_exposure") { + const v = String(req.body.value || "").toLowerCase() === "on" ? "on" : "off"; + const devBinding = await loadDeviceBinding(); + await devBinding.setSoloLanExposure(db, v); + return res.redirect("/dashboard/maker-lab?settings=1&saved=1"); + } + + if (a === "import_lesson") { + const raw = String(req.body.lesson_json || ""); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + return layout({ + title: "Import lesson — parse error", + content: renderLessonImportResult({ errors: [`JSON parse error: ${err.message}`], raw, escapeHtml }), + }); + } + const { validateLesson } = await import(pathToFileURL(resolve(__dirname, "../server/lesson-validator.js")).href); + const { valid, errors } = validateLesson(parsed); + if (!valid) { + return layout({ + title: "Import lesson — validation failed", + content: renderLessonImportResult({ errors, raw, escapeHtml }), + }); + } + // Write to ~/.crow/bundles/maker-lab/curriculum/custom/.json + const { mkdirSync, writeFileSync } = await import("node:fs"); + const home = process.env.HOME || "."; + const dir = resolve(home, ".crow/bundles/maker-lab/curriculum/custom"); + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(resolve(dir, `${parsed.id}.json`), JSON.stringify(parsed, null, 2) + "\n"); + } catch (err) { + return layout({ + title: "Import lesson — write failed", + content: renderLessonImportResult({ errors: [`Failed to write: ${err.message}`], raw, escapeHtml }), + }); + } + return res.redirect(`/dashboard/maker-lab?lessons=1&imported=${encodeURIComponent(parsed.id)}`); + } + + if (a === "delete_custom_lesson") { + const id = String(req.body.lesson_id || "").replace(/[^\w-]/g, ""); + if (!id) return res.redirect("/dashboard/maker-lab?lessons=1"); + const { unlinkSync, existsSync: existsFn } = await import("node:fs"); + const home = process.env.HOME || "."; + const path = resolve(home, ".crow/bundles/maker-lab/curriculum/custom", `${id}.json`); + try { + if (existsFn(path)) unlinkSync(path); + } catch {} + return res.redirect(`/dashboard/maker-lab?lessons=1&deleted=${encodeURIComponent(id)}`); + } + + if (a === "unbind_device") { + const fp = String(req.body.fingerprint || ""); + if (!fp) return res.redirect("/dashboard/maker-lab?settings=1"); + const devBinding = await loadDeviceBinding(); + await devBinding.unbindDevice(db, fp); + return res.redirect("/dashboard/maker-lab?settings=1&unbound=1"); + } + if (a === "revoke_batch") { const batchId = String(req.body.batch_id || ""); const reason = String(req.body.reason || "").slice(0, 500); @@ -302,6 +368,63 @@ export default { }); } + // Lessons view + if (req.query.lessons) { + const { readdirSync, readFileSync, existsSync: existsFn } = await import("node:fs"); + const home = process.env.HOME || "."; + const customDir = resolve(home, ".crow/bundles/maker-lab/curriculum/custom"); + const bundledDirs = [ + { band: "5-9", dir: resolve(__dirname, "../curriculum/age-5-9") }, + { band: "10-13", dir: resolve(__dirname, "../curriculum/age-10-13") }, + { band: "14+", dir: resolve(__dirname, "../curriculum/age-14+") }, + ]; + const loadDir = (dir) => { + if (!existsFn(dir)) return []; + try { + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => { + try { + const parsed = JSON.parse(readFileSync(resolve(dir, f), "utf8")); + return { file: f, lesson: parsed }; + } catch (err) { + return { file: f, error: err.message }; + } + }); + } catch { return []; } + }; + const bundled = bundledDirs.map((b) => ({ band: b.band, items: loadDir(b.dir) })); + const custom = loadDir(customDir); + return layout({ + title: "Maker Lab — Lessons", + content: renderLessonsView({ + bundled, custom, + imported: String(req.query.imported || ""), + deleted: String(req.query.deleted || ""), + escapeHtml, + }), + }); + } + + // Settings view + if (req.query.settings) { + const devBinding = await loadDeviceBinding(); + const [lanExposure, devices, mode] = await Promise.all([ + devBinding.getSoloLanExposure(db), + devBinding.listBoundDevices(db), + getMode(), + ]); + return layout({ + title: "Maker Lab — Settings", + content: renderSettingsView({ + mode, lanExposure, devices, + saved: req.query.saved === "1", + unbound: req.query.unbound === "1", + escapeHtml, + }), + }); + } + // Per-learner edit view if (req.query.edit) { const lid = Number(req.query.edit); @@ -738,6 +861,10 @@ function renderMainView({ mode, err, pendingDelete, showGuestPicker, learners, a
${css()} ${errBanner} +
${modeTabs}${guestSection.includes('guest-btn') ? guestSection : ''}
${guestSection.includes('guest-btn') ? '' : guestSection}
${modeHeadline}
@@ -789,6 +916,8 @@ function css() { .session-list { list-style: none; padding: 0; margin: 0.75rem 0 0 0; display: grid; gap: 0.35rem; } .session-list li { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.4rem 0.6rem; background: rgba(0,0,0,0.05); border-radius: 4px; } .session-list li .meta { color: var(--muted, #888); font-size: 0.8em; margin-left: auto; } + .top-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-bottom: 0.5rem; } + .btn.small { padding: 0.2rem 0.6rem; font-size: 0.8em; } `; } @@ -970,3 +1099,230 @@ function renderTranscriptsView({ learner, settings, transcripts, escapeHtml }) {
`; } + +// ─── Render: Settings view ──────────────────────────────────────────────── + +function renderSettingsView({ mode, lanExposure, devices, saved, unbound, escapeHtml }) { + const banner = saved ? `` + : unbound ? `` + : ""; + + const soloSection = mode === "solo" ? ` +
+

Solo mode — LAN exposure

+

+ By default the solo kiosk is loopback-only — only browsers on the Crow host itself can use it. + Turning this on lets you open /kiosk/ from any device on your LAN, but every new + device must first be "bound" by signing in to Crow's Nest on it. +

+
+ + +
+
+ ` : ` +
+

Solo mode settings

+

Switch to Solo mode from the main page to configure LAN exposure and bound devices.

+
+ `; + + const devicesSection = ` +
+

Bound devices (${devices.length})

+

+ Devices that have been bound as solo kiosks. Unbinding forces a device to re-authenticate on next use. +

+ ${devices.length ? ` + + + + ${devices.map((d) => ` + + + + + + + + `).join("")} + +
FingerprintLearnerBoundLast seen
${escapeHtml(d.fingerprint.slice(0, 12))}…${escapeHtml(d.learner_name || "(deleted)")}${escapeHtml(d.bound_at)}${escapeHtml(d.last_seen_at || "—")} +
+ + + +
+
+ ` : `

(no bound devices)

`} +
+ `; + + return ` + +
+
+ ← Back +

Settings

+ mode: ${escapeHtml(mode)} +
+ ${banner} + ${soloSection} + ${devicesSection} +
+

Data handling

+

See bundles/maker-lab/DATA-HANDLING.md for what data Maker Lab stores, how long, and the COPPA / GDPR-K posture.

+
+
+ `; +} + +// ─── Render: Lessons view ───────────────────────────────────────────────── + +function renderLessonsView({ bundled, custom, imported, deleted, escapeHtml }) { + const banner = imported + ? `` + : deleted + ? `` + : ""; + + const renderItem = (item, isCustom) => { + if (item.error) { + return `
  • ${escapeHtml(item.file)}: ${escapeHtml(item.error)}
  • `; + } + const l = item.lesson; + return ` +
  • +
    + ${escapeHtml(l.title || l.id)} + ${escapeHtml(l.age_band || "?")} + ${escapeHtml(l.surface || "?")} + ${l.reading_level != null ? `grade ${escapeHtml(String(l.reading_level))}` : ""} +
    +
    ${escapeHtml(l.id)}
    + ${isCustom ? ` +
    + + + +
    + ` : `bundled`} +
  • + `; + }; + + const bundledHtml = bundled + .filter((b) => b.items.length > 0) + .map((b) => ` +

    Age band ${escapeHtml(b.band)} (${b.items.length})

    +
      ${b.items.map((x) => renderItem(x, false)).join("")}
    + `).join(""); + + const customHtml = ` +

    Custom lessons (${custom.length})

    + ${custom.length + ? `
      ${custom.map((x) => renderItem(x, true)).join("")}
    ` + : `

    No custom lessons yet. Use the form below to add one.

    `} + `; + + const importForm = ` +
    +

    Import a lesson

    +

    + Paste a lesson JSON below. It will be validated against bundles/maker-lab/curriculum/SCHEMA.md. + Valid lessons land in ~/.crow/bundles/maker-lab/curriculum/custom/<id>.json and appear immediately — no restart. +

    +
    + + +
    + + Cancel +
    +
    +
    + `; + + return ` + +
    +
    + ← Back +

    Lessons

    +
    + ${banner} + ${bundledHtml} + ${customHtml} + ${importForm} +
    + `; +} + +function renderLessonImportResult({ errors, raw, escapeHtml }) { + return ` + +
    + +

    Your input

    +
    ${escapeHtml(raw)}
    + ← Back +
    + `; +} diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js index 772c3d8..fa6a454 100644 --- a/bundles/maker-lab/panel/routes.js +++ b/bundles/maker-lab/panel/routes.js @@ -217,20 +217,104 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { }); // ─── /kiosk/ — Blockly surface ───────────────────────────────────────── + // + // Three paths: + // 1. Valid session cookie → serve the Blockly kiosk. + // 2. No cookie, solo mode, loopback request → auto-mint a default-learner + // session and redirect. (Solo-mode convenience.) + // 3. No cookie, solo mode, LAN exposure on, known bound device → same. + // 4. No cookie, solo mode, LAN exposure on, unknown device but admin + // crow_session present → bind the device and auto-mint. + // 5. Everything else → "Ask a grown-up" screen. router.get("/kiosk/", async (req, res) => { const guard = await requireKioskSession(req, db); - if (!guard.ok) { - if (guard.reason === "session_invalid") clearSessionCookie(req, res); - return res.status(401).type("html").send(` + if (guard.ok) { + // Path 1: already-valid session. Serve Blockly. + return serveBlockly(req, res); + } + if (guard.reason === "session_invalid") clearSessionCookie(req, res); + + // Solo auto-redeem check. Only runs when the deployment mode is "solo". + const modeRow = await db.execute({ + sql: "SELECT value FROM dashboard_settings WHERE key = 'maker_lab.mode'", + args: [], + }); + const mode = modeRow.rows[0]?.value || "family"; + if (mode !== "solo") { + return noSessionResponse(res); + } + + const deviceBinding = await import(pathToFileURL(resolve(__dirname, "../server/device-binding.js")).href); + const sessionsMod = await import(pathToFileURL(resolve(__dirname, "../server/sessions.js")).href); + + const fp = fingerprint(req); + const loopback = deviceBinding.isLoopback(req); + const lanExposure = await deviceBinding.getSoloLanExposure(db); + + if (!loopback && lanExposure !== "on") { + // Path 5 (LAN call in loopback-only posture): refuse. + return res.status(403).type("html").send(` - Ask a grown-up + Kiosk unavailable -

    Ask a grown-up to start a new session.

    -

    This kiosk doesn't have an active session right now.

    +

    This kiosk is set to loopback-only.

    +

    Open it on the Crow host itself, or ask the admin to enable LAN exposure in Maker Lab → Settings.

    `); } + let allow = loopback; + let bindingLabel = null; + + if (!loopback && lanExposure === "on") { + const bound = await deviceBinding.getBoundDevice(db, fp); + if (bound) { + // Path 3: known bound device. Touch timestamp and allow. + await deviceBinding.touchBoundDevice(db, fp); + allow = true; + } else if (await deviceBinding.hasAdminSession(req)) { + // Path 4: admin's Crow's Nest cookie is present — bind this device. + const defaultLearnerId = await deviceBinding.ensureDefaultLearner(db); + await deviceBinding.bindDevice(db, { fingerprint: fp, learnerId: defaultLearnerId, label: null }); + bindingLabel = "bound via admin session"; + allow = true; + } else { + // Path 5 (unknown LAN device, no admin cookie): bind-prompt page. + return res.status(401).type("html").send(` + + Set up this kiosk + +

    This device isn't set up yet.

    +

    Sign in to Crow's Nest first, then reload this page.

    +

    Sign in to Crow's Nest

    + `); + } + } + + if (!allow) return noSessionResponse(res); + + // Auto-mint a default-learner session and redeem in one shot. + try { + const learnerId = await deviceBinding.ensureDefaultLearner(db); + const r = await sessionsMod.mintSessionForLearner(db, { learnerId, durationMin: 60 }); + // Claim the code immediately (server-side). Mirrors what /kiosk/r/:code does. + await db.execute({ + sql: `UPDATE maker_redemption_codes SET used_at = datetime('now'), claimed_by_fingerprint = ? + WHERE code = ? AND used_at IS NULL`, + args: [fp, r.redemptionCode], + }); + await db.execute({ + sql: `UPDATE maker_sessions SET kiosk_device_id = ? WHERE token = ?`, + args: [fp, r.sessionToken], + }); + setSessionCookie(req, res, signCookie(r.sessionToken, fp)); + return res.redirect(302, "/kiosk/"); + } catch (err) { + return res.status(500).type("text").send(`Solo redeem failed: ${err.message}`); + } + }); + + function serveBlockly(req, res) { const blocklyIndex = resolve(__dirname, "../public/blockly/index.html"); if (!existsSync(blocklyIndex)) { return res.type("html").send(` @@ -241,7 +325,17 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { `); } res.sendFile(blocklyIndex); - }); + } + + function noSessionResponse(res) { + return res.status(401).type("html").send(` + + Ask a grown-up + +

    Ask a grown-up to start a new session.

    +

    This kiosk doesn't have an active session right now.

    + `); + } // Blockly static assets served under /kiosk/blockly/* router.get("/kiosk/blockly/*", async (req, res) => { diff --git a/bundles/maker-lab/server/device-binding.js b/bundles/maker-lab/server/device-binding.js new file mode 100644 index 0000000..8134381 --- /dev/null +++ b/bundles/maker-lab/server/device-binding.js @@ -0,0 +1,173 @@ +/** + * Maker Lab — solo-mode device binding helpers. + * + * Solo mode lets a single learner auto-redeem without a QR handoff. That + * posture is SAFE only in two situations: + * (A) the kiosk and the Crow host are the same machine (loopback), or + * (B) the LAN kiosk has been explicitly bound after an admin Nest login. + * + * This module handles the server-side checks for both. + */ + +import { verifySession } from "../../../servers/gateway/dashboard/auth.js"; + +/** + * Is the request coming from loopback (same-host)? + * Handles IPv4, IPv6, and IPv4-mapped-IPv6. + */ +export function isLoopback(req) { + // req.ip respects the `trust proxy` setting. For a same-host request it's + // 127.0.0.1 or ::1. Behind Caddy/reverse-proxy, x-forwarded-for should be + // set correctly. Defense-in-depth: also check raw socket. + const candidates = [ + req.ip, + req.socket?.remoteAddress, + req.connection?.remoteAddress, + ].filter(Boolean); + for (const addr of candidates) { + const a = String(addr).replace(/^::ffff:/, ""); + if (a === "127.0.0.1" || a === "::1" || a === "localhost") return true; + } + return false; +} + +/** + * Read the maker_lab.solo_lan_exposure setting. Default "off". + */ +export async function getSoloLanExposure(db) { + try { + const r = await db.execute({ + sql: "SELECT value FROM dashboard_settings WHERE key = 'maker_lab.solo_lan_exposure'", + args: [], + }); + return r.rows[0]?.value === "on" ? "on" : "off"; + } catch { + return "off"; + } +} + +export async function setSoloLanExposure(db, value) { + const v = value === "on" ? "on" : "off"; + await db.execute({ + sql: `INSERT INTO dashboard_settings (key, value) VALUES ('maker_lab.solo_lan_exposure', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + args: [v], + }); + return v; +} + +/** + * Check whether a fingerprint is recorded in maker_bound_devices. + * Returns the row (with learner_id) or null. + */ +export async function getBoundDevice(db, fingerprint) { + if (!fingerprint) return null; + try { + const r = await db.execute({ + sql: `SELECT * FROM maker_bound_devices WHERE fingerprint = ?`, + args: [fingerprint], + }); + return r.rows[0] || null; + } catch { + return null; + } +} + +export async function touchBoundDevice(db, fingerprint) { + try { + await db.execute({ + sql: `UPDATE maker_bound_devices SET last_seen_at = datetime('now') WHERE fingerprint = ?`, + args: [fingerprint], + }); + } catch {} +} + +/** + * Bind a device fingerprint to a learner. Idempotent (INSERT ON CONFLICT DO UPDATE). + */ +export async function bindDevice(db, { fingerprint, learnerId, label }) { + await db.execute({ + sql: `INSERT INTO maker_bound_devices (fingerprint, learner_id, label, bound_at, last_seen_at) + VALUES (?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(fingerprint) DO UPDATE SET + learner_id = excluded.learner_id, + label = COALESCE(excluded.label, maker_bound_devices.label), + last_seen_at = datetime('now')`, + args: [fingerprint, learnerId, label || null], + }); +} + +export async function unbindDevice(db, fingerprint) { + await db.execute({ + sql: `DELETE FROM maker_bound_devices WHERE fingerprint = ?`, + args: [fingerprint], + }); +} + +export async function listBoundDevices(db) { + try { + const r = await db.execute({ + sql: `SELECT bd.fingerprint, bd.learner_id, bd.label, bd.bound_at, bd.last_seen_at, + rp.name AS learner_name + FROM maker_bound_devices bd + LEFT JOIN research_projects rp ON rp.id = bd.learner_id + ORDER BY bd.last_seen_at DESC NULLS LAST`, + args: [], + }); + return r.rows; + } catch { + return []; + } +} + +/** + * Check the request for a valid Crow's Nest session cookie. + * Used to auto-bind on first visit when the admin is already logged in. + */ +export async function hasAdminSession(req) { + try { + const cookies = parseCookies(req.headers.cookie); + const token = cookies.crow_session; + if (!token) return false; + return await verifySession(token); + } catch { + return false; + } +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const seg of String(header).split(/;\s*/)) { + const idx = seg.indexOf("="); + if (idx < 0) continue; + out[seg.slice(0, idx).trim()] = seg.slice(idx + 1); + } + return out; +} + +/** + * Ensure a default learner exists for solo mode. If none, create one with + * consent captured (by the admin initiating the binding). Returns the id. + */ +export async function ensureDefaultLearner(db) { + const r = await db.execute({ + sql: `SELECT id FROM research_projects WHERE type = 'learner_profile' ORDER BY id LIMIT 1`, + args: [], + }); + if (r.rows.length) return Number(r.rows[0].id); + // Create with age null — admin can edit in the settings panel. + const ins = await db.execute({ + sql: `INSERT INTO research_projects (name, type, created_at, updated_at) + VALUES ('Default learner', 'learner_profile', datetime('now'), datetime('now')) + RETURNING id`, + args: [], + }); + const lid = Number(ins.rows[0].id); + await db.execute({ + sql: `INSERT INTO maker_learner_settings (learner_id, age, consent_captured_at) + VALUES (?, NULL, datetime('now'))`, + args: [lid], + }); + return lid; +} diff --git a/bundles/maker-lab/server/lesson-validator.js b/bundles/maker-lab/server/lesson-validator.js new file mode 100644 index 0000000..864dcbc --- /dev/null +++ b/bundles/maker-lab/server/lesson-validator.js @@ -0,0 +1,102 @@ +/** + * Maker Lab — lesson JSON validator. + * + * Shared by the `maker_validate_lesson` MCP tool and the panel's + * "Import lesson" flow. Returns { valid, errors } — errors are + * specific strings a teacher/parent can read without reading code. + */ + +const VALID_AGE_BANDS = ["5-9", "10-13", "14+"]; +const VALID_SURFACES = ["blockly", "scratch", "kolibri"]; +const ID_RE = /^[a-zA-Z0-9][\w-]{0,99}$/; + +export function validateLesson(lesson) { + const errors = []; + if (!lesson || typeof lesson !== "object" || Array.isArray(lesson)) { + return { valid: false, errors: ["top-level value must be a JSON object"] }; + } + + // Required fields + const required = ["id", "title", "surface", "age_band", "steps", "canned_hints"]; + for (const k of required) { + if (!(k in lesson)) errors.push(`missing: ${k}`); + } + + if (lesson.id != null && !ID_RE.test(String(lesson.id))) { + errors.push(`id must match ${ID_RE.source} (alphanumeric start, letters/digits/underscore/dash, max 100)`); + } + if (lesson.title != null && typeof lesson.title !== "string") { + errors.push("title must be a string"); + } + if (lesson.surface != null && !VALID_SURFACES.includes(lesson.surface)) { + errors.push(`surface must be one of: ${VALID_SURFACES.join(", ")}`); + } + if (lesson.age_band != null && !VALID_AGE_BANDS.includes(lesson.age_band)) { + errors.push(`age_band must be one of: ${VALID_AGE_BANDS.join(", ")}`); + } + + // canned_hints + if (lesson.canned_hints != null) { + if (!Array.isArray(lesson.canned_hints)) { + errors.push("canned_hints must be an array of strings"); + } else { + if (lesson.canned_hints.length === 0) { + errors.push("canned_hints must have at least one entry"); + } + for (let i = 0; i < lesson.canned_hints.length; i++) { + if (typeof lesson.canned_hints[i] !== "string") { + errors.push(`canned_hints[${i}] must be a string`); + } else if (lesson.canned_hints[i].length > 500) { + errors.push(`canned_hints[${i}] too long (>500 chars)`); + } + } + } + } + + // reading_level: for 5-9 must be <= 3 + if (lesson.reading_level != null) { + if (typeof lesson.reading_level !== "number") { + errors.push("reading_level must be a number"); + } else if (lesson.age_band === "5-9" && lesson.reading_level > 3) { + errors.push(`reading_level must be <= 3 for age_band '5-9' (got ${lesson.reading_level})`); + } + } + + // steps + if (lesson.steps != null) { + if (!Array.isArray(lesson.steps)) { + errors.push("steps must be an array"); + } else { + if (lesson.steps.length === 0) { + errors.push("steps must have at least one entry"); + } + for (let i = 0; i < lesson.steps.length; i++) { + const s = lesson.steps[i]; + if (!s || typeof s !== "object") { + errors.push(`steps[${i}] must be an object`); + continue; + } + if (!s.prompt || typeof s.prompt !== "string") { + errors.push(`steps[${i}].prompt missing or not a string`); + } else if (s.prompt.length > 1000) { + errors.push(`steps[${i}].prompt too long (>1000 chars)`); + } + } + } + } + + // Optional fields sanity-check + if (lesson.goal != null && typeof lesson.goal !== "string") { + errors.push("goal must be a string"); + } + if (lesson.starter_workspace != null && typeof lesson.starter_workspace !== "string") { + errors.push("starter_workspace must be a string (Blockly XML)"); + } + if (lesson.tags != null) { + if (!Array.isArray(lesson.tags) || lesson.tags.some((t) => typeof t !== "string")) { + errors.push("tags must be an array of strings"); + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/bundles/maker-lab/server/server.js b/bundles/maker-lab/server/server.js index 0c29811..b5a1228 100644 --- a/bundles/maker-lab/server/server.js +++ b/bundles/maker-lab/server/server.js @@ -669,35 +669,9 @@ export function createMakerLabServer(db, options = {}) { "Validate a lesson JSON against the schema. Returns specific errors so custom lesson authors (teachers/parents) can fix them without reading code.", { lesson: z.record(z.any()) }, async ({ lesson }) => { - const errs = []; - const required = ["id", "title", "surface", "age_band", "steps", "canned_hints"]; - for (const k of required) { - if (!(k in lesson)) errs.push(`missing: ${k}`); - } - if (lesson.age_band && !["5-9", "10-13", "14+"].includes(lesson.age_band)) { - errs.push(`age_band must be one of 5-9 | 10-13 | 14+`); - } - if (lesson.canned_hints && !Array.isArray(lesson.canned_hints)) { - errs.push("canned_hints must be an array of strings"); - } - if (Array.isArray(lesson.canned_hints)) { - for (let i = 0; i < lesson.canned_hints.length; i++) { - if (typeof lesson.canned_hints[i] !== "string") { - errs.push(`canned_hints[${i}] must be a string`); - } - } - } - if (lesson.reading_level != null && (typeof lesson.reading_level !== "number" || lesson.reading_level > 3)) { - errs.push("reading_level must be a number ≤ 3 for the 5-9 band"); - } - if (Array.isArray(lesson.steps)) { - for (let i = 0; i < lesson.steps.length; i++) { - const s = lesson.steps[i]; - if (!s || typeof s !== "object") { errs.push(`steps[${i}] must be an object`); continue; } - if (!s.prompt) errs.push(`steps[${i}].prompt missing`); - } - } - return mcpOk({ valid: errs.length === 0, errors: errs }); + const { validateLesson } = await import("./lesson-validator.js"); + const { valid, errors } = validateLesson(lesson); + return mcpOk({ valid, errors }); } ); From 8f342bb06425a4e2d7889e31458ad741dc15d5f4 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:25:19 -0500 Subject: [PATCH 7/9] =?UTF-8?q?Maker=20Lab:=20close=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20full=20curriculum,=20dynamic=20toolbox,=20tutor-eve?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining Phase 2 items before moving to Phase 3. Curriculum — 10 lessons total (was 3) for ages 5-9: - blockly-01-move-cat, -02-repeat, -03-on-click (existing, rewritten to declare a toolbox + success_check) - blockly-04-two-in-a-row sequences: stack two Do blocks - blockly-05-count-to-ten loops: repeat with count - blockly-06-change-the-words sequences: edit text literals - blockly-07-big-and-small conditions: compare with numbers - blockly-08-loops-inside-loops loops: nesting, multiplicative feel - blockly-09-yes-or-no conditions: if / else - blockly-10-capstone-party capstone combining all prior concepts Lesson schema + validator (server/lesson-validator.js): - `toolbox`: either a flat array of block-type strings or a { categories: [{ name, colour, blocks }] } structure. Validator checks both forms. - `success_check.required_blocks`: array of block-type strings. If any are missing from the workspace when the kid hits "I'm done!", the progression is blocked with `success_check.message_missing`. - SCHEMA.md documents both fields with examples. Blockly kiosk (public/blockly/): - Removed the hard-coded in index.html. - tutor-bridge.js now builds the toolbox dynamically from the active lesson's `toolbox` field. Default shadows for common block types (controls_repeat_ext → TIMES=4, text_print → TEXT="Hi!", logic_compare → A=5 B=3) so blocks drop in ready to run instead of empty. - "I'm done!" now enforces success_check before POSTing progress — missing blocks surface the lesson's `message_missing` in the hint bubble and speak it via TTS instead of marking complete. Companion tutor-event patch (applied at container build time): - bundles/companion/scripts/patch-tutor-event.py — idempotent Python patcher. Detects a marker string and exits early if already patched. Registers a new "tutor-event" WS message type and appends an `_handle_tutor_event` method to WebSocketHandler. The handler NEVER treats the payload as user text; it POSTs {session_token, question, level, ...} to http://127.0.0.1:3004/maker-lab/api/hint-internal, then speaks the filtered reply via the per-client TTS engine. - entrypoint.sh invokes the patcher after patch-auto-group.py. - Dockerfile copies patch-tutor-event.py into /app/scripts/. - Activation requires a companion rebuild. Until then the Phase 2 MVP `speechSynthesis` path continues to work. Maker Lab internal hint endpoint (panel/routes.js): - New POST /maker-lab/api/hint-internal — loopback-only (refuses non- loopback IPs with 403), accepts {session_token, surface, question, level, lesson_id?, canned_hints?}, validates the token directly (no cookie/fingerprint binding — access is loopback-restricted), runs the same handleHintRequest pipeline as /kiosk/api/hint. - This is the endpoint the companion's patched handler calls. Phase 2.5 kiosk launcher (scripts/launch-kiosk.sh): - Opens the Blockly kiosk tile-left (2/3) and the AI Companion web UI tile-right (1/3) in Chromium --app windows (or Firefox with xdotool-assisted positioning). Same-host solo-mode deployment. - Until Phase 3 ships pet-mode, this is the documented "Phase 2.5 visual layout via crow-wm" path. README.md: first-pass bundle docs covering quick start, modes, hint pipeline, lesson authoring, companion integration (Phase 2 MVP vs post-rebuild upgrade), and a Phase 3 preview. --- bundles/companion/Dockerfile | 1 + bundles/companion/scripts/entrypoint.sh | 6 + .../companion/scripts/patch-tutor-event.py | 167 ++++++++++++++++++ bundles/maker-lab/README.md | 95 ++++++++++ bundles/maker-lab/curriculum/SCHEMA.md | 39 +++- .../age-5-9/blockly-01-move-cat.json | 17 +- .../curriculum/age-5-9/blockly-02-repeat.json | 23 ++- .../age-5-9/blockly-03-on-click.json | 24 ++- .../age-5-9/blockly-04-two-in-a-row.json | 28 +++ .../age-5-9/blockly-05-count-to-ten.json | 31 ++++ .../age-5-9/blockly-06-change-the-words.json | 30 ++++ .../age-5-9/blockly-07-big-and-small.json | 33 ++++ .../blockly-08-loops-inside-loops.json | 31 ++++ .../age-5-9/blockly-09-yes-or-no.json | 30 ++++ .../age-5-9/blockly-10-capstone-party.json | 34 ++++ bundles/maker-lab/panel/routes.js | 46 +++++ bundles/maker-lab/public/blockly/index.html | 14 +- .../maker-lab/public/blockly/tutor-bridge.js | 88 ++++++++- bundles/maker-lab/scripts/launch-kiosk.sh | 91 ++++++++++ bundles/maker-lab/server/lesson-validator.js | 45 +++++ 20 files changed, 838 insertions(+), 35 deletions(-) create mode 100644 bundles/companion/scripts/patch-tutor-event.py create mode 100644 bundles/maker-lab/README.md create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-04-two-in-a-row.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-05-count-to-ten.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-06-change-the-words.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-07-big-and-small.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-08-loops-inside-loops.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-09-yes-or-no.json create mode 100644 bundles/maker-lab/curriculum/age-5-9/blockly-10-capstone-party.json create mode 100755 bundles/maker-lab/scripts/launch-kiosk.sh diff --git a/bundles/companion/Dockerfile b/bundles/companion/Dockerfile index b5255a4..8254724 100644 --- a/bundles/companion/Dockerfile +++ b/bundles/companion/Dockerfile @@ -25,6 +25,7 @@ COPY scripts/inject-wm.sh /app/scripts/inject-wm.sh COPY scripts/crow-wm.js /app/scripts/crow-wm.js COPY scripts/patch-tool-calling.py /app/scripts/patch-tool-calling.py COPY scripts/patch-auto-group.py /app/scripts/patch-auto-group.py +COPY scripts/patch-tutor-event.py /app/scripts/patch-tutor-event.py COPY scripts/crow-webrtc.js /app/scripts/crow-webrtc.js COPY scripts/inject-webrtc.sh /app/scripts/inject-webrtc.sh COPY scripts/crow-voice-panel.js /app/scripts/crow-voice-panel.js diff --git a/bundles/companion/scripts/entrypoint.sh b/bundles/companion/scripts/entrypoint.sh index 42a12a7..b94e29f 100644 --- a/bundles/companion/scripts/entrypoint.sh +++ b/bundles/companion/scripts/entrypoint.sh @@ -14,6 +14,12 @@ python3 /app/scripts/patch-tool-calling.py echo "Applying auto-group patch..." python3 /app/scripts/patch-auto-group.py +# Maker Lab: patch tutor-event WebSocket handler (idempotent; no-op if missing) +if [ -f /app/scripts/patch-tutor-event.py ]; then + echo "Applying Maker Lab tutor-event patch..." + python3 /app/scripts/patch-tutor-event.py || echo " (patch failed; companion will continue without tutor-event support)" +fi + # Generate conf.yaml from Crow's AI profiles echo "Generating config from Crow AI profiles..." APP_DIR=/app uv run python3 /app/scripts/generate-config.py diff --git a/bundles/companion/scripts/patch-tutor-event.py b/bundles/companion/scripts/patch-tutor-event.py new file mode 100644 index 0000000..b325991 --- /dev/null +++ b/bundles/companion/scripts/patch-tutor-event.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Idempotently patch Open-LLM-VTuber's websocket_handler.py to add a +`tutor-event` message type that Maker Lab's Blockly kiosk fires to +request a scaffolded hint spoken via the companion's TTS. + +Contract (matches bundles/companion/patches/backend/0001-tutor-event-handler.patch): + + { type: "tutor-event", + event: "hint_request", + session_token: str, + surface: str, + question: str, + level: int, + lesson_id: str | None, + canned_hints: [str] | None } + +The handler NEVER treats the payload as user text — only the filtered +return from maker-lab's /maker-lab/api/hint-internal endpoint reaches TTS. + +Safe to re-run. Detects the marker string and exits early if already patched. +""" + +import re +import sys +from pathlib import Path + +TARGET = Path("/app/src/open_llm_vtuber/websocket_handler.py") +MARKER = "# [maker-lab] tutor-event handler" + +REGISTRATION = ' "heartbeat": self._handle_heartbeat,' +REGISTRATION_PATCHED = ( + ' "heartbeat": self._handle_heartbeat,\n' + ' "tutor-event": self._handle_tutor_event, # [maker-lab]' +) + +HANDLER_METHOD = ''' + + async def _handle_tutor_event( + self, websocket, client_uid: str, data: dict + ): + """Maker Lab tutor-event handler. See bundles/companion/patches/backend/0001. + + Validates session_token against maker-lab's loopback-only hint endpoint. + Plays the filtered response via the per-client TTS engine. Never echoes + the raw payload. + """ + # [maker-lab] tutor-event handler + import aiohttp + from loguru import logger as _ll_logger + + event = str(data.get("event") or "") + token = str(data.get("session_token") or "") + if not token or event != "hint_request": + return + + url = "http://127.0.0.1:3004/maker-lab/api/hint-internal" + payload = { + "session_token": token, + "surface": str(data.get("surface") or "companion"), + "question": str(data.get("question") or ""), + "level": int(data.get("level") or 1), + "lesson_id": data.get("lesson_id"), + "canned_hints": data.get("canned_hints"), + } + + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as s: + async with s.post(url, json=payload) as resp: + if resp.status != 200: + _ll_logger.warning( + f"maker-lab hint endpoint returned {resp.status}" + ) + reply = {"text": "Your tutor is taking a nap. Try the lesson hints on your own!"} + else: + reply = await resp.json() + except Exception as err: + _ll_logger.warning(f"maker-lab hint call failed: {err}") + reply = {"text": "Your tutor is taking a nap. Try the lesson hints on your own!"} + + text = str(reply.get("text") or "").strip() + if not text: + return + + context = self.client_contexts.get(client_uid) + if not context: + return + + # Speak via the per-client TTS engine, not the LLM path. This means + # the filtered text bypasses the conversation agent entirely — no + # risk of it being fed back into an LLM turn, no memory writes, no + # secondary tool calls. + try: + tts = getattr(context, "tts_engine", None) + if tts and hasattr(tts, "generate_audio"): + audio = await tts.generate_audio(text) + # Send a display-text message first so the frontend shows it. + import json + await websocket.send_text(json.dumps({ + "type": "full-text", + "text": text, + })) + if audio is not None: + # Different TTS adapters yield different payloads; we do a + # best-effort passthrough. If the audio path isn't wired, + # the text frame alone still gives the kid feedback. + try: + from .utils.stream_audio import prepare_audio_payload + payload_frame = prepare_audio_payload(audio) + await websocket.send_text(json.dumps(payload_frame)) + except Exception: + pass + except Exception as err: + _ll_logger.warning(f"tutor-event TTS failed: {err}") +''' + + +def main() -> int: + if not TARGET.exists(): + print(f"[maker-lab patch] {TARGET} not found; skipping", file=sys.stderr) + return 0 + src = TARGET.read_text() + if MARKER in src: + print("[maker-lab patch] tutor-event handler already present; skipping") + return 0 + + if REGISTRATION not in src: + print( + "[maker-lab patch] anchor line not found — refusing to patch. " + "Upstream websocket_handler.py may have moved. Inspect manually.", + file=sys.stderr, + ) + return 1 + + patched = src.replace(REGISTRATION, REGISTRATION_PATCHED, 1) + + # Append the handler method just before the final class-level closing + # (whichever class ends the file). We append to end of file and rely on + # Python indentation — the HANDLER_METHOD is indented to class level. + # Locate the WebSocketHandler class; append the method before the next + # top-level def/class or EOF. + # Simpler: append the method inside the class definition by locating + # the last method of WebSocketHandler. We do it by regex match on the + # closing of the last known method `_handle_config_switch` and insert + # the new method after it. + anchor_re = re.compile( + r"(async def _handle_config_switch.*?await context\.handle_config_switch\(websocket, config_file_name\))", + re.DOTALL, + ) + m = anchor_re.search(patched) + if not m: + print( + "[maker-lab patch] _handle_config_switch anchor not found — refusing to patch.", + file=sys.stderr, + ) + return 1 + insertion_point = m.end() + patched = patched[:insertion_point] + HANDLER_METHOD + patched[insertion_point:] + + TARGET.write_text(patched) + print("[maker-lab patch] tutor-event handler installed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bundles/maker-lab/README.md b/bundles/maker-lab/README.md new file mode 100644 index 0000000..7f97de7 --- /dev/null +++ b/bundles/maker-lab/README.md @@ -0,0 +1,95 @@ +# Maker Lab + +Scaffolded AI learning companion paired with FOSS maker surfaces (Blockly first). Hint-ladder pedagogy, per-learner memory, age-banded personas, classroom-capable. + +## Quick start + +After installing the bundle via the Extensions panel: + +1. **Create a learner** — Maker Lab panel → `+ Add learner`. Name + age + consent checkbox. Age drives persona (≤9 kid-tutor, 10–13 tween-tutor, 14+ adult-tutor). +2. **Start a session** — Click Start session on a learner's card. Panel returns a short redemption code + QR + full URL. +3. **Open the kiosk** — On any LAN device: scan the QR or visit `/kiosk/r/`. Code is one-shot, 10-min TTL. +4. **Session ends** — Admin clicks End on the panel (5s graceful flush) or Force End (immediate). + +## Modes + +- **Solo** — one implicit learner, no QR handoff. Loopback-only by default. Toggle LAN exposure in Settings to allow other devices (first visit requires a Crow's Nest sign-in). +- **Family** — per-learner Start session. +- **Classroom** — multi-select learners + Bulk Start → printable QR sheet. Revoke the whole batch with one action. +- **Guest** — "Try it without saving" in any mode. Ephemeral, no memory, no artifact save, 30-min cap. + +## Tutor hint pipeline + +Every `maker_hint` call runs through: + +1. **State machine check** — if session is `ending` / `revoked`, returns a canned wrap-up hint. +2. **Rate limit** — 6/min per session. +3. **LLM call** — any OpenAI-compatible endpoint (`MAKER_LAB_LLM_ENDPOINT`, default `http://localhost:11434/v1` = Ollama). +4. **Output filter** — Flesch-Kincaid grade cap (kid-tutor only), kid-safe blocklist, per-persona word budget. +5. **Canned fallback** — filtered-out or LLM failure returns a lesson canned hint, never a raw error. + +See `DATA-HANDLING.md` for exactly what data is stored, COPPA/GDPR-K posture, and export/delete paths. + +## Same-host kiosk (Pi-style deployment) + +When the Crow host IS the kiosk (Raspberry Pi + attached display, solo mode), the Blockly page and the AI Companion's web UI should appear tiled side-by-side. + +`scripts/launch-kiosk.sh` opens both in Chromium `--app` windows (2/3 left, 1/3 right). Usage: + +```bash +# Default: localhost:3002 (gateway) + localhost:12393 (companion) +./scripts/launch-kiosk.sh + +# Custom host +CROW_HOST=pi5.local ./scripts/launch-kiosk.sh +``` + +This is the web-tiled fallback. Phase 3 adds an Electron/Tauri pet-mode overlay that floats the mascot on top of the Blockly window without a separate browser tab. + +## Lesson authoring + +Lessons live at: + +- `curriculum/age-5-9/*.json` — bundled (10 lessons: sequences, loops, events, conditions, capstone) +- `~/.crow/bundles/maker-lab/curriculum/custom/*.json` — your additions + +Authors can add lessons via the admin panel (Lessons → Import) without touching code. Schema: `curriculum/SCHEMA.md`. + +Each lesson declares: + +- `toolbox` — what Blockly blocks are available (per-category) +- `success_check.required_blocks` — block types the workspace must contain before "I'm done!" is accepted +- `canned_hints` — fallback hints when the LLM is unavailable or filtered + +## Companion integration + +**Phase 2 MVP** (what ships today): kiosk browser uses `speechSynthesis` to voice the hint. No companion modifications required. + +**Phase 2 upgrade path** (requires companion rebuild): apply the `tutor-event` patch so the AI Companion's per-client TTS voices the hint through the mascot. Two pieces: + +1. `bundles/companion/scripts/patch-tutor-event.py` — idempotent patcher, wired into the companion's `entrypoint.sh`. Modifies `/app/src/open_llm_vtuber/websocket_handler.py` at container startup. +2. `bundles/maker-lab/panel/routes.js` — serves `POST /maker-lab/api/hint-internal` (loopback-only) for the patched handler to call. + +To apply: + +```bash +# Rebuild the companion image to pick up the patch script. +cd bundles/companion && docker compose build && docker compose up -d + +# The patcher runs at container startup. Logs show: +# Applying Maker Lab tutor-event patch... +# [maker-lab patch] tutor-event handler installed +# (or "already present; skipping" on subsequent starts) +``` + +The kiosk's Blockly page sends a `tutor-event` WebSocket message to the companion, the companion calls `/maker-lab/api/hint-internal` with the session token, and the filtered reply plays through the mascot's TTS. + +Until the companion is rebuilt, the kiosk continues using `speechSynthesis`. + +## Phase 3 preview (not yet shipped) + +- Electron/Tauri pet-mode overlay — floating Live2D mascot on top of the Blockly window +- Cubism SDK install-time fetch (see `bundles/companion/CUBISM-LICENSE.md`) +- Submodule + patch pipeline for the companion backend + renderer + +See `PHASE-0-REPORT.md` for the spike report that informed these decisions. diff --git a/bundles/maker-lab/curriculum/SCHEMA.md b/bundles/maker-lab/curriculum/SCHEMA.md index 7bf02bd..41f41ba 100644 --- a/bundles/maker-lab/curriculum/SCHEMA.md +++ b/bundles/maker-lab/curriculum/SCHEMA.md @@ -27,10 +27,47 @@ errors like `missing: canned_hints[]` or `reading_level must be a number <= 3`. | `goal` | string | Short description for the tutor's system prompt. | | `reading_level` | number | Self-declared grade. For `5-9`, must be `<= 3`. | | `starter_workspace` | string | Blockly XML to prefill the workspace. | -| `success_check` | object | Lightweight pattern-match against submitted workspace XML. | +| `toolbox` | object or array | Per-lesson Blockly toolbox. See below. Falls back to the default toolbox if absent. | +| `success_check` | object | Checks the kid's workspace when they click "I'm done!". See below. | | `background` | string | Lesson cover image filename in the bundle's assets dir. | | `tags` | array of strings | For organization. | +### `toolbox` + +Two forms, both accepted: + +**Simple** — just a list of Blockly block type names: + +```json +"toolbox": ["controls_repeat_ext", "text_print", "math_number"] +``` + +**Grouped by category** — better for a real kid UX: + +```json +"toolbox": { + "categories": [ + { "name": "Repeat", "colour": "#84cc16", "blocks": ["controls_repeat_ext"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] +} +``` + +Common block types a 5-9 lesson will use: `controls_repeat_ext` (repeat N times), `controls_if`, `text_print` (stand-in for "do something"), `math_number`, `logic_compare`. + +### `success_check` + +Minimum viable: a list of block types that must appear in the kid's workspace before "I'm done!" succeeds. + +```json +"success_check": { + "required_blocks": ["controls_repeat_ext", "text_print"], + "message_missing": "Don't forget to use a Repeat block!" +} +``` + +If `success_check` is absent, any workspace is accepted. + ## Example ```json diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json b/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json index 132c043..90256d3 100644 --- a/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json @@ -4,15 +4,24 @@ "surface": "blockly", "age_band": "5-9", "reading_level": 2, - "goal": "Drag a move block into the workspace and click play to move the cat.", + "goal": "Drag a move block into the workspace and press play to move the cat.", "steps": [ - { "prompt": "Drag the move block into the workspace." }, - { "prompt": "Click the green play button!" } + { "prompt": "Find the Do block and drag it into the workspace." }, + { "prompt": "Press play!" } ], "canned_hints": [ "Look for the block shaped like a little arrow!", - "Try dragging it right under the 'when start' block.", + "Try dragging it under the start block.", "The green play button is at the top!" ], + "toolbox": { + "categories": [ + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] + }, + "success_check": { + "required_blocks": ["text_print"], + "message_missing": "Drag a Do block into the workspace first!" + }, "tags": ["sequences", "starter"] } diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json b/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json index 764ee75..c4fca1f 100644 --- a/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json @@ -4,17 +4,28 @@ "surface": "blockly", "age_band": "5-9", "reading_level": 2, - "goal": "Use a repeat block so the cat moves four times.", + "goal": "Use a Repeat block so the cat moves four times.", "steps": [ - { "prompt": "Find the repeat block (it has a little hole inside)." }, - { "prompt": "Put the move block inside the repeat." }, + { "prompt": "Find the Repeat block. It has a little hole inside." }, + { "prompt": "Put a Do block inside the Repeat." }, { "prompt": "Change the number to 4." }, { "prompt": "Press play and watch the cat go!" } ], "canned_hints": [ - "A repeat block is like saying 'do it again, and again, and again!'", - "You can drop other blocks INSIDE the repeat block.", - "Try changing the number inside the repeat. What changes?" + "A Repeat block is like saying 'do it again, and again, and again!'", + "You can drop other blocks inside the Repeat block.", + "Try changing the number. What changes?" ], + "toolbox": { + "categories": [ + { "name": "Repeat", "colour": "#84cc16", "blocks": ["controls_repeat_ext"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] }, + { "name": "Numbers", "colour": "#fbbf24", "blocks": ["math_number"] } + ] + }, + "success_check": { + "required_blocks": ["controls_repeat_ext", "text_print"], + "message_missing": "You need a Repeat block with a Do block inside!" + }, "tags": ["loops", "repetition"] } diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json b/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json index fc3063f..2fc2406 100644 --- a/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json @@ -4,16 +4,26 @@ "surface": "blockly", "age_band": "5-9", "reading_level": 3, - "goal": "Make the cat do something only when you click it.", + "goal": "Use an If block so something only happens when a question is true.", "steps": [ - { "prompt": "Grab the 'when clicked' block." }, - { "prompt": "Put a move block under it." }, - { "prompt": "Press play, then click the cat!" } + { "prompt": "Find the If block." }, + { "prompt": "Put a Do block inside it." }, + { "prompt": "Press play and see what happens!" } ], "canned_hints": [ - "Events are like doorbells — the code runs when something rings them!", - "The cat won't move until you click on it. That's on purpose.", - "Try putting more than one move block under 'when clicked'." + "An If block is like a question: only do this IF the answer is yes.", + "Put the Do block inside the If, under the 'do' label.", + "You can build more than one step inside." ], + "toolbox": { + "categories": [ + { "name": "If", "colour": "#ec4899", "blocks": ["controls_if", "logic_boolean"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] + }, + "success_check": { + "required_blocks": ["controls_if", "text_print"], + "message_missing": "Put a Do block inside an If block." + }, "tags": ["events", "interaction"] } diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-04-two-in-a-row.json b/bundles/maker-lab/curriculum/age-5-9/blockly-04-two-in-a-row.json new file mode 100644 index 0000000..861c9e3 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-04-two-in-a-row.json @@ -0,0 +1,28 @@ +{ + "id": "blockly-04-two-in-a-row", + "title": "Two Steps in a Row", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Stack two Do blocks one after the other. The computer runs them in order, top to bottom.", + "steps": [ + { "prompt": "Drag one Do block into the workspace." }, + { "prompt": "Drag a second Do block and snap it right underneath." }, + { "prompt": "Press play." } + ], + "canned_hints": [ + "Blocks that touch run one after the other, from top to bottom.", + "Try changing what the second block says!", + "You can stack as many as you want — like LEGO." + ], + "toolbox": { + "categories": [ + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] + }, + "success_check": { + "required_blocks": ["text_print"], + "message_missing": "Try stacking two Do blocks!" + }, + "tags": ["sequences"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-05-count-to-ten.json b/bundles/maker-lab/curriculum/age-5-9/blockly-05-count-to-ten.json new file mode 100644 index 0000000..1b354c5 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-05-count-to-ten.json @@ -0,0 +1,31 @@ +{ + "id": "blockly-05-count-to-ten", + "title": "Count to Ten", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Use a Repeat block with the number 10 so the cat counts to ten.", + "steps": [ + { "prompt": "Drag a Repeat block into the workspace." }, + { "prompt": "Change the number inside it to 10." }, + { "prompt": "Put a Do block inside the Repeat." }, + { "prompt": "Press play!" } + ], + "canned_hints": [ + "The number in the Repeat block says how many times to do it.", + "Click the number to change it.", + "What if you put 100 there? (Press play and see!)" + ], + "toolbox": { + "categories": [ + { "name": "Repeat", "colour": "#84cc16", "blocks": ["controls_repeat_ext"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] }, + { "name": "Numbers", "colour": "#fbbf24", "blocks": ["math_number"] } + ] + }, + "success_check": { + "required_blocks": ["controls_repeat_ext", "text_print"], + "message_missing": "You need a Repeat block with a Do block inside!" + }, + "tags": ["loops", "counting"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-06-change-the-words.json b/bundles/maker-lab/curriculum/age-5-9/blockly-06-change-the-words.json new file mode 100644 index 0000000..5e71e32 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-06-change-the-words.json @@ -0,0 +1,30 @@ +{ + "id": "blockly-06-change-the-words", + "title": "Make It Your Own", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 2, + "goal": "Change the words inside a Do block so the cat says what YOU want.", + "steps": [ + { "prompt": "Drag a Do block into the workspace." }, + { "prompt": "Click the text inside the Do block." }, + { "prompt": "Type a silly word!" }, + { "prompt": "Press play and watch." } + ], + "canned_hints": [ + "The text inside the block is what the cat says.", + "You can type any letters you want.", + "Try adding a second Do block with a different word!" + ], + "toolbox": { + "categories": [ + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] }, + { "name": "Words", "colour": "#a855f7", "blocks": ["text"] } + ] + }, + "success_check": { + "required_blocks": ["text_print"], + "message_missing": "Drag a Do block and change the words!" + }, + "tags": ["sequences", "customization"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-07-big-and-small.json b/bundles/maker-lab/curriculum/age-5-9/blockly-07-big-and-small.json new file mode 100644 index 0000000..6505796 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-07-big-and-small.json @@ -0,0 +1,33 @@ +{ + "id": "blockly-07-big-and-small", + "title": "Big and Small Numbers", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 3, + "goal": "Compare two numbers. The If block only runs when 5 is bigger than 3.", + "steps": [ + { "prompt": "Drag an If block into the workspace." }, + { "prompt": "Put a compare block in the diamond slot." }, + { "prompt": "Type two numbers, one bigger than the other." }, + { "prompt": "Put a Do block inside the If." }, + { "prompt": "Press play." } + ], + "canned_hints": [ + "The diamond slot wants a yes-or-no answer.", + "5 bigger than 3 means yes!", + "Try flipping them. What happens?" + ], + "toolbox": { + "categories": [ + { "name": "If", "colour": "#ec4899", "blocks": ["controls_if"] }, + { "name": "Compare", "colour": "#22c55e", "blocks": ["logic_compare"] }, + { "name": "Numbers", "colour": "#fbbf24", "blocks": ["math_number"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] + }, + "success_check": { + "required_blocks": ["controls_if", "logic_compare", "text_print"], + "message_missing": "You need an If block with a compare block inside — and a Do block." + }, + "tags": ["conditions", "comparison"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-08-loops-inside-loops.json b/bundles/maker-lab/curriculum/age-5-9/blockly-08-loops-inside-loops.json new file mode 100644 index 0000000..0d1edff --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-08-loops-inside-loops.json @@ -0,0 +1,31 @@ +{ + "id": "blockly-08-loops-inside-loops", + "title": "Loops Inside Loops", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 3, + "goal": "Put a Repeat block inside another Repeat block. That's a lot of times.", + "steps": [ + { "prompt": "Drag a Repeat block into the workspace. Set it to 3." }, + { "prompt": "Drag a second Repeat block inside the first one. Set the inside one to 4." }, + { "prompt": "Put a Do block inside the inside Repeat." }, + { "prompt": "Press play — how many times does the Do happen?" } + ], + "canned_hints": [ + "The outer Repeat runs the inner Repeat every single time.", + "3 times 4 is 12 — that's how many times the Do happens.", + "Try different numbers and see!" + ], + "toolbox": { + "categories": [ + { "name": "Repeat", "colour": "#84cc16", "blocks": ["controls_repeat_ext"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] }, + { "name": "Numbers", "colour": "#fbbf24", "blocks": ["math_number"] } + ] + }, + "success_check": { + "required_blocks": ["controls_repeat_ext", "text_print"], + "message_missing": "You need Repeat blocks with a Do block inside." + }, + "tags": ["loops", "nesting", "advanced"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-09-yes-or-no.json b/bundles/maker-lab/curriculum/age-5-9/blockly-09-yes-or-no.json new file mode 100644 index 0000000..d8de46e --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-09-yes-or-no.json @@ -0,0 +1,30 @@ +{ + "id": "blockly-09-yes-or-no", + "title": "Yes or No?", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 3, + "goal": "Use If / else to run one thing when yes, and another thing when no.", + "steps": [ + { "prompt": "Drag an If block." }, + { "prompt": "Click the little gear on the If block and add an else." }, + { "prompt": "Put one Do in the If and a different Do in the else." }, + { "prompt": "Try both true and false in the yes-or-no slot." } + ], + "canned_hints": [ + "The gear lets you add an else. Drag it onto the If.", + "One Do runs if yes, the other Do runs if no.", + "The yes-or-no slot takes true or false blocks." + ], + "toolbox": { + "categories": [ + { "name": "If", "colour": "#ec4899", "blocks": ["controls_if", "logic_boolean"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] } + ] + }, + "success_check": { + "required_blocks": ["controls_if", "logic_boolean", "text_print"], + "message_missing": "Use an If with a true/false block and a Do." + }, + "tags": ["conditions", "branching"] +} diff --git a/bundles/maker-lab/curriculum/age-5-9/blockly-10-capstone-party.json b/bundles/maker-lab/curriculum/age-5-9/blockly-10-capstone-party.json new file mode 100644 index 0000000..27d2417 --- /dev/null +++ b/bundles/maker-lab/curriculum/age-5-9/blockly-10-capstone-party.json @@ -0,0 +1,34 @@ +{ + "id": "blockly-10-capstone-party", + "title": "Dance Party (Capstone)", + "surface": "blockly", + "age_band": "5-9", + "reading_level": 3, + "goal": "Put everything together: a Repeat that runs an If, with different Do blocks for yes and no.", + "steps": [ + { "prompt": "Start with a Repeat set to 5." }, + { "prompt": "Put an If / else inside the Repeat." }, + { "prompt": "Drop a compare block in the yes-or-no slot (5 bigger than 3 works fine)." }, + { "prompt": "Put one Do in the If, a different Do in the else." }, + { "prompt": "Press play and watch your party!" } + ], + "canned_hints": [ + "You know all these blocks — just stack them!", + "Repeat is the outside shell. Everything else goes inside.", + "No wrong answer here — make it yours." + ], + "toolbox": { + "categories": [ + { "name": "Repeat", "colour": "#84cc16", "blocks": ["controls_repeat_ext"] }, + { "name": "If", "colour": "#ec4899", "blocks": ["controls_if"] }, + { "name": "Compare", "colour": "#22c55e", "blocks": ["logic_compare", "logic_boolean"] }, + { "name": "Do", "colour": "#3b82f6", "blocks": ["text_print"] }, + { "name": "Numbers", "colour": "#fbbf24", "blocks": ["math_number"] } + ] + }, + "success_check": { + "required_blocks": ["controls_repeat_ext", "controls_if", "text_print"], + "message_missing": "The capstone wants a Repeat, an If, and a Do — all in one." + }, + "tags": ["capstone", "loops", "conditions", "sequences"] +} diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js index fa6a454..ba7642f 100644 --- a/bundles/maker-lab/panel/routes.js +++ b/bundles/maker-lab/panel/routes.js @@ -563,6 +563,52 @@ export default function makerLabKioskRouter(/* dashboardAuth */) { } }); + // ─── /maker-lab/api/hint-internal ──────────────────────────────────── + // Loopback-only internal endpoint. Called by the companion's + // tutor-event WebSocket handler (Python backend, same host) to resolve + // a hint from a session_token WITHOUT the kiosk cookie/fingerprint + // binding — the token itself is the credential here. Because the + // endpoint is restricted to loopback, only processes on the Crow host + // (i.e., the companion container which runs with network_mode: host + // AND thus shares the host's loopback) can reach it. + + router.post("/maker-lab/api/hint-internal", express_json(), async (req, res) => { + const deviceBinding = await import(pathToFileURL(resolve(__dirname, "../server/device-binding.js")).href); + if (!deviceBinding.isLoopback(req)) { + return res.status(403).json({ error: "loopback_only" }); + } + const { session_token, surface, question, level, lesson_id, canned_hints } = req.body || {}; + if (!session_token || typeof session_token !== "string") { + return res.status(400).json({ error: "bad_session_token" }); + } + if (!question || typeof question !== "string") { + return res.status(400).json({ error: "bad_question" }); + } + // Resolve session directly (no cookie check; this endpoint trusts the + // token because access is loopback-restricted). + const session = await resolveSessionRow(db, session_token); + if (!session) return res.status(401).json({ error: "session_invalid" }); + if (session.state === "revoked") return res.status(410).json({ error: "revoked" }); + + const { handleHintRequest } = await import(pathToFileURL(resolve(__dirname, "../server/hint-pipeline.js")).href); + try { + const result = await handleHintRequest(db, { + sessionToken: session_token, + session, + surface: String(surface || "companion").slice(0, 50), + question: question.slice(0, 2000), + level: Math.min(3, Math.max(1, Number(level) || 1)), + lessonId: lesson_id ? String(lesson_id).slice(0, 100) : null, + cannedHints: Array.isArray(canned_hints) + ? canned_hints.map((h) => String(h).slice(0, 500)).slice(0, 10) + : null, + }); + return res.json(result); + } catch (err) { + return res.status(500).json({ error: "hint_failed", detail: err.message }); + } + }); + // ─── /kiosk/api/end ──────────────────────────────────────────────────── router.post("/kiosk/api/end", async (req, res) => { diff --git a/bundles/maker-lab/public/blockly/index.html b/bundles/maker-lab/public/blockly/index.html index 1e1ba2b..0f59820 100644 --- a/bundles/maker-lab/public/blockly/index.html +++ b/bundles/maker-lab/public/blockly/index.html @@ -33,18 +33,8 @@ - - + + diff --git a/bundles/maker-lab/public/blockly/tutor-bridge.js b/bundles/maker-lab/public/blockly/tutor-bridge.js index bc42de3..8609eaf 100644 --- a/bundles/maker-lab/public/blockly/tutor-bridge.js +++ b/bundles/maker-lab/public/blockly/tutor-bridge.js @@ -97,6 +97,7 @@ const offlineChip = document.getElementById("offlineChip"); let hintLevel = 1; let currentLesson = null; let currentSurface = "blockly"; +let currentWorkspace = null; hintClose?.addEventListener("click", () => { hintBubble.hidden = true; @@ -152,6 +153,20 @@ async function postProgress(payload) { } doneBtn?.addEventListener("click", async () => { + // success_check gate: missing required blocks → nudge, don't mark complete. + const required = currentLesson?.success_check?.required_blocks; + if (Array.isArray(required) && required.length && currentWorkspace) { + const present = workspaceBlockTypes(currentWorkspace); + const missing = required.filter((t) => !present.has(t)); + if (missing.length) { + const msg = currentLesson.success_check.message_missing + || `Almost! Your workspace is missing: ${missing.join(", ")}.`; + hintText.textContent = msg; + hintBubble.hidden = false; + speak(msg); + return; + } + } const payload = { surface: currentSurface, activity: currentLesson?.id || "unknown", @@ -164,8 +179,8 @@ doneBtn?.addEventListener("click", async () => { hintText.textContent = "Great job! 🎉"; hintBubble.hidden = false; hintLevel = 1; + speak("Great job!"); } catch { - // Queue for when we come back online. await queuePush(payload); offlineChip.hidden = false; } @@ -280,25 +295,88 @@ async function loadLesson(id) { } } -function mountBlockly() { +// Default shadow values for common block types so they drop in ready to run +// (otherwise `controls_repeat_ext` has an empty TIMES slot that won't connect +// to plain number blocks). +const BLOCK_SHADOWS = { + controls_repeat_ext: { + TIMES: { type: "math_number", fields: { NUM: 4 } }, + }, + text_print: { + TEXT: { type: "text", fields: { TEXT: "Hi!" } }, + }, + logic_compare: { + A: { type: "math_number", fields: { NUM: 5 } }, + B: { type: "math_number", fields: { NUM: 3 } }, + }, +}; + +function blockEntry(type) { + const entry = { kind: "block", type }; + const shadows = BLOCK_SHADOWS[type]; + if (shadows) { + entry.inputs = {}; + for (const [slot, shadow] of Object.entries(shadows)) { + entry.inputs[slot] = { shadow }; + } + } + return entry; +} + +// Build a Blockly JSON toolbox from lesson.toolbox (either shape) or a +// sensible default when no lesson is loaded. +function buildToolbox(lessonToolbox) { + if (!lessonToolbox) { + return { + kind: "categoryToolbox", + contents: [ + { kind: "category", name: "Do", colour: "#3b82f6", contents: [blockEntry("text_print")] }, + ], + }; + } + if (Array.isArray(lessonToolbox)) { + return { kind: "flyoutToolbox", contents: lessonToolbox.map(blockEntry) }; + } + if (lessonToolbox.categories) { + return { + kind: "categoryToolbox", + contents: lessonToolbox.categories.map((c) => ({ + kind: "category", + name: c.name, + colour: c.colour || "#3b82f6", + contents: (c.blocks || []).map(blockEntry), + })), + }; + } + return buildToolbox(null); +} + +function mountBlockly(lessonToolbox) { if (typeof Blockly === "undefined") { titleEl.textContent = "Blockly couldn't load. Ask a grown-up to check the network."; return null; } - const toolbox = document.getElementById("toolbox"); return Blockly.inject("blocklyArea", { - toolbox, + toolbox: buildToolbox(lessonToolbox), trashcan: true, grid: { spacing: 20, length: 3, colour: "#ccc", snap: true }, zoom: { controls: true, wheel: true, startScale: 1.1 }, }); } +// Walk all blocks in the workspace and collect their type names. Used by the +// success_check gate on "Done" — missing required types block progression. +function workspaceBlockTypes(ws) { + if (!ws || typeof ws.getAllBlocks !== "function") return new Set(); + return new Set(ws.getAllBlocks(false).map((b) => b.type)); +} + async function init() { const urlLesson = new URLSearchParams(location.search).get("lesson") || "blockly-01-move-cat"; await loadContext(); await loadLesson(urlLesson); - const ws = mountBlockly(); + const ws = mountBlockly(currentLesson?.toolbox); + currentWorkspace = ws; // Count workspace changes as activity (per plan's allowlist: hint request, // progress POST, Blockly workspace change, explicit heartbeat — NOT // mouse-move or scroll). diff --git a/bundles/maker-lab/scripts/launch-kiosk.sh b/bundles/maker-lab/scripts/launch-kiosk.sh new file mode 100755 index 0000000..89a1339 --- /dev/null +++ b/bundles/maker-lab/scripts/launch-kiosk.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# +# Maker Lab — same-host kiosk launcher. +# +# Opens the Blockly kiosk tile-left and the AI Companion web UI tile-right +# in fullscreen-ish browser windows. Designed for solo-mode-on-same-host +# deployments (e.g., a Raspberry Pi display running Chromium) where there's +# no separate grown-up admin device to hand off a QR code. +# +# Usage: +# ./launch-kiosk.sh # uses http://localhost +# CROW_HOST=pi5.local ./launch-kiosk.sh # custom host +# BROWSER=firefox ./launch-kiosk.sh # force a specific browser +# +# Requirements: +# - `xdotool` (optional, for window positioning) +# - A browser on PATH: chromium, google-chrome, chromium-browser, or firefox +# - Maker Lab installed in solo mode with a default learner + LAN exposure +# set appropriately (loopback-only works since this runs on the host). +# +# Not a substitute for the Phase 3 pet-mode overlay — this is the web-tiled +# fallback that ships with the bundle until Phase 3 lands. + +set -euo pipefail + +CROW_HOST="${CROW_HOST:-localhost}" +CROW_PROTO="${CROW_PROTO:-http}" +BROWSER="${BROWSER:-}" +GATEWAY_PORT="${CROW_GATEWAY_PORT:-3002}" +COMPANION_PORT="${COMPANION_PORT:-12393}" + +BLOCKLY_URL="${CROW_PROTO}://${CROW_HOST}:${GATEWAY_PORT}/kiosk/" +COMPANION_URL="${CROW_PROTO}://${CROW_HOST}:${COMPANION_PORT}/" + +# Pick a browser. +pick_browser() { + if [ -n "$BROWSER" ]; then echo "$BROWSER"; return; fi + for b in chromium chromium-browser google-chrome chrome firefox; do + if command -v "$b" >/dev/null 2>&1; then echo "$b"; return; fi + done + echo "" +} + +B="$(pick_browser)" +if [ -z "$B" ]; then + echo "launch-kiosk: no supported browser found. Install chromium or firefox, or set BROWSER." >&2 + exit 1 +fi + +# Screen size for tiling (defaults to common 1920x1080 if xrandr missing). +SCREEN_W=1920 +SCREEN_H=1080 +if command -v xrandr >/dev/null 2>&1; then + read -r SCREEN_W SCREEN_H < <(xrandr --current | awk '/\*/ {print $1; exit}' | awk -F'x' '{print $1" "$2}' || echo "1920 1080") +fi +LEFT_W=$((SCREEN_W * 2 / 3)) +RIGHT_W=$((SCREEN_W - LEFT_W)) + +case "$B" in + chromium*|google-chrome|chrome) + "$B" --app="$BLOCKLY_URL" \ + --window-position=0,0 \ + --window-size="${LEFT_W},${SCREEN_H}" \ + --user-data-dir="$HOME/.crow/bundles/maker-lab/chromium-profile-blockly" \ + >/dev/null 2>&1 & + sleep 1 + "$B" --app="$COMPANION_URL" \ + --window-position="${LEFT_W},0" \ + --window-size="${RIGHT_W},${SCREEN_H}" \ + --user-data-dir="$HOME/.crow/bundles/maker-lab/chromium-profile-companion" \ + >/dev/null 2>&1 & + ;; + firefox) + "$B" --new-window "$BLOCKLY_URL" >/dev/null 2>&1 & + sleep 1 + "$B" --new-window "$COMPANION_URL" >/dev/null 2>&1 & + # Firefox can't split windows from the CLI — user needs to tile manually, + # or call xdotool / the host WM. + if command -v xdotool >/dev/null 2>&1; then + sleep 2 + # Best-effort: the two newest Firefox windows get tiled side-by-side. + readarray -t WINS < <(xdotool search --name "Mozilla Firefox" | tail -2) + if [ "${#WINS[@]}" -ge 2 ]; then + xdotool windowmove "${WINS[0]}" 0 0 windowsize "${WINS[0]}" "$LEFT_W" "$SCREEN_H" + xdotool windowmove "${WINS[1]}" "$LEFT_W" 0 windowsize "${WINS[1]}" "$RIGHT_W" "$SCREEN_H" + fi + fi + ;; +esac + +echo "launched: $BLOCKLY_URL (2/3 left) + $COMPANION_URL (1/3 right)" diff --git a/bundles/maker-lab/server/lesson-validator.js b/bundles/maker-lab/server/lesson-validator.js index 864dcbc..2d65a75 100644 --- a/bundles/maker-lab/server/lesson-validator.js +++ b/bundles/maker-lab/server/lesson-validator.js @@ -98,5 +98,50 @@ export function validateLesson(lesson) { } } + // toolbox: either an array of block-type strings or { categories: [...] } + if (lesson.toolbox != null) { + const tb = lesson.toolbox; + if (Array.isArray(tb)) { + if (!tb.every((t) => typeof t === "string")) { + errors.push("toolbox (array form) must be an array of block-type strings"); + } + } else if (typeof tb === "object") { + if (!Array.isArray(tb.categories)) { + errors.push("toolbox.categories must be an array"); + } else { + for (let i = 0; i < tb.categories.length; i++) { + const cat = tb.categories[i]; + if (!cat || typeof cat !== "object") { errors.push(`toolbox.categories[${i}] must be an object`); continue; } + if (typeof cat.name !== "string") errors.push(`toolbox.categories[${i}].name must be a string`); + if (!Array.isArray(cat.blocks) || !cat.blocks.every((b) => typeof b === "string")) { + errors.push(`toolbox.categories[${i}].blocks must be an array of block-type strings`); + } + if (cat.colour != null && typeof cat.colour !== "string") { + errors.push(`toolbox.categories[${i}].colour must be a string`); + } + } + } + } else { + errors.push("toolbox must be an array or an object with a categories array"); + } + } + + // success_check: { required_blocks: [], message_missing? } + if (lesson.success_check != null) { + const sc = lesson.success_check; + if (!sc || typeof sc !== "object" || Array.isArray(sc)) { + errors.push("success_check must be an object"); + } else { + if (sc.required_blocks != null) { + if (!Array.isArray(sc.required_blocks) || !sc.required_blocks.every((b) => typeof b === "string")) { + errors.push("success_check.required_blocks must be an array of block-type strings"); + } + } + if (sc.message_missing != null && typeof sc.message_missing !== "string") { + errors.push("success_check.message_missing must be a string"); + } + } + } + return { valid: errors.length === 0, errors }; } From 222e1757cce3fe3bfa79ff9fc60a42d1c9d39963 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:31:26 -0500 Subject: [PATCH 8/9] F.0 (part 1/2): Caddy federation helpers + hardware gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the groundwork for the 8 federated-app bundles enumerated in the Phase 2 plan (Matrix-Dendrite, Mastodon, GoToSocial, Pixelfed, PeerTube, Funkwhale, Lemmy, WriteFreely). No federated app ships in this PR — this is pure platform infra so the apps in F.1+ are thin wrappers. Caddy side: - bundles/caddy/server/federation-profiles.js — 4 canned profiles (matrix, activitypub, peertube, generic-ws) with the directives each app family needs (websocket upgrade, 40MB/8GB body, forwarded headers, 300s/1800s timeouts) plus builders for the standard /.well-known/ JSON payloads (matrix-server, matrix-client, nodeinfo) - bundles/caddy/server/caddyfile.js — upsertRawSite() helper for idempotent site-block replacement. Parser already handled :8448 and inner blocks; round-trip verified via smoke test - bundles/caddy/server/server.js — 4 new MCP tools: caddy_add_federation_site — profile-aware site block caddy_set_wellknown — standalone /.well-known/ handler (e.g. matrix-server delegation on an apex domain) caddy_add_matrix_federation_port — :8448 site block with its own LE cert (refuses if the same domain already has matrix-server delegation — enforces "one or the other, not both") caddy_cert_health — ok/warning/error per domain, surfaces staging-cert use and near-expiry that would otherwise stay silent until outage - bundles/caddy/scripts/post-install.sh — creates the crow-federation external docker network (idempotent) - bundles/caddy/docker-compose.yml — Caddy now joins crow-federation so federated apps in F.1+ become reachable by docker service name with no host port publish - bundles/caddy/skills/caddy.md — full doc for the 4 new tools, covering the matrix-8448-vs-well-known either/or and the shared- network model Gateway side: - servers/gateway/hardware-gate.js — checkInstall() refuses installs whose min_ram_mb exceeds effective RAM (MemAvailable + 0.5 × SSD-backed swap + 0.5 × zram; SD-card swap explicitly excluded) minus already-committed RAM (sum of recommended_ram_mb across installed bundles) minus a flat 512 MB host reserve. Warns but allows when under the recommended threshold. Unit-tested against Pi-with-SD-swap, Pi-with-SSD-swap, and Pi-with-zram fixtures - servers/gateway/routes/bundles.js — hardware gate wired into POST /bundles/api/install before the consent-token check. CLI-only force_install bypass (request body, never UI) Design notes: - Caddyfile on-disk remains the source of truth. All new tools go through upsertRawSite() so hand-edits outside the managed blocks survive round-trips, and re-running a tool with the same domain replaces instead of duplicating - Effective RAM is deliberately pessimistic: SD-card swap does not count as headroom even though /proc/meminfo reports it in SwapFree. zram counts at half-weight because it's compressed RAM, not true extra capacity. Host-reserve keeps the base OS + gateway responsive - Hardware gate is a refuse-by-default mechanism with a CLI override; the web UI never surfaces --force-install Part 2/2 (follow-up): shared rate-limiter (SQLite-backed token bucket), storage-translators (per-app S3 env-var schema mapping), init-db entry for rate_limit_buckets, end-to-end verification on grackle with the cert-health panel card. --- bundles/caddy/docker-compose.yml | 10 + bundles/caddy/scripts/post-install.sh | 21 ++ bundles/caddy/server/caddyfile.js | 30 ++ bundles/caddy/server/federation-profiles.js | 166 +++++++++++ bundles/caddy/server/server.js | 296 ++++++++++++++++++++ bundles/caddy/skills/caddy.md | 116 +++++++- servers/gateway/hardware-gate.js | 245 ++++++++++++++++ servers/gateway/routes/bundles.js | 26 ++ 8 files changed, 905 insertions(+), 5 deletions(-) create mode 100755 bundles/caddy/scripts/post-install.sh create mode 100644 bundles/caddy/server/federation-profiles.js create mode 100644 servers/gateway/hardware-gate.js diff --git a/bundles/caddy/docker-compose.yml b/bundles/caddy/docker-compose.yml index 4f874ec..00b5945 100644 --- a/bundles/caddy/docker-compose.yml +++ b/bundles/caddy/docker-compose.yml @@ -17,10 +17,20 @@ # via XDG_DATA_HOME). We chmod 0700 on first init. # config/ — Caddy's auto-save state (XDG_CONFIG_HOME) +networks: + crow-federation: + external: true + # Created by bundles/caddy/scripts/post-install.sh on install. + # Federated app bundles (F.1+) join this same network so Caddy can reach + # their upstreams by docker service name without publishing host ports. + services: caddy: image: caddy:2-alpine container_name: crow-caddy + networks: + - default + - crow-federation ports: - "0.0.0.0:80:80" - "0.0.0.0:443:443" diff --git a/bundles/caddy/scripts/post-install.sh b/bundles/caddy/scripts/post-install.sh new file mode 100755 index 0000000..999d076 --- /dev/null +++ b/bundles/caddy/scripts/post-install.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Caddy bundle post-install hook. +# +# Creates the `crow-federation` external docker network that federated app +# bundles (F.1 onward) join. Idempotent: existing network is left alone. +# +# Wired into the installer via the bundle lifecycle — see +# servers/gateway/routes/bundles.js which runs scripts/post-install.sh (if +# present) after `docker compose up -d` succeeds. + +set -euo pipefail + +NETWORK="crow-federation" + +if docker network inspect "$NETWORK" >/dev/null 2>&1; then + echo "docker network $NETWORK already exists" + exit 0 +fi + +docker network create --driver bridge "$NETWORK" +echo "created docker network $NETWORK" diff --git a/bundles/caddy/server/caddyfile.js b/bundles/caddy/server/caddyfile.js index c084c40..c59c73a 100644 --- a/bundles/caddy/server/caddyfile.js +++ b/bundles/caddy/server/caddyfile.js @@ -187,6 +187,36 @@ export function appendSite(source, domain, upstream, extra = "") { return base + sep + block; } +/** + * Append a pre-rendered site block (address + body already formatted). + * Body text is indented with two spaces per line. Used by + * caddy_add_federation_site and caddy_add_matrix_federation_port where the + * inner directives include nested blocks that don't fit the simple + * `reverse_proxy ` shape. + * + * If a block with the same address already exists, it is replaced in place + * (idempotent emit — reviewer requirement for federation profiles). + */ +export function upsertRawSite(source, address, bodyText) { + const indented = bodyText + .split("\n") + .map((l) => (l.length ? " " + l : "")) + .join("\n"); + const block = `${address} {\n${indented}\n}\n`; + + const sites = parseSites(source); + const match = sites.find((s) => s.address === address); + if (match) { + const before = source.slice(0, match.start); + const after = source.slice(match.end); + const joined = (before + block + after).replace(/\n{3,}/g, "\n\n"); + return joined; + } + const base = source.endsWith("\n") || source === "" ? source : source + "\n"; + const sep = base && !base.endsWith("\n\n") ? "\n" : ""; + return base + sep + block; +} + /** * Remove a site block matching the given address. * If multiple blocks match (rare), only the first is removed. diff --git a/bundles/caddy/server/federation-profiles.js b/bundles/caddy/server/federation-profiles.js new file mode 100644 index 0000000..7db5e2f --- /dev/null +++ b/bundles/caddy/server/federation-profiles.js @@ -0,0 +1,166 @@ +/** + * Federation profiles for Caddy. + * + * Each profile encodes the Caddyfile directives a federated app needs beyond + * a plain `reverse_proxy`: websocket upgrades, large upload bodies, longer + * proxy timeouts, and optional /.well-known/ handlers for actor/server + * discovery. Profiles are designed to be idempotent — re-running + * caddy_add_federation_site with the same domain replaces the block, never + * duplicates. + * + * Profiles: + * matrix — Matrix client-server on HTTPS. Pair with + * caddy_add_matrix_federation_port for :8448 OR + * caddy_set_wellknown with matrix-server delegation. + * activitypub — Mastodon / GoToSocial / Pixelfed / WriteFreely / + * Funkwhale / Lemmy / BookWyrm / Mobilizon. Emits webfinger + * + host-meta + nodeinfo well-known handlers. + * peertube — PeerTube. Large body (8 GiB), long timeouts for uploads. + * generic-ws — Generic HTTP + websocket upgrade. Escape hatch. + */ + +/** + * Directive lines emitted inside a site block for each profile. + * Each string is one line, leading whitespace stripped; appendSite indents. + */ +const PROFILE_DIRECTIVES = { + matrix: [ + // Matrix client-server: large body for media, keep-alive for sync long-poll. + "request_body {", + " max_size 50MB", + "}", + "reverse_proxy {upstream} {", + " transport http {", + " versions 1.1 2", + " read_timeout 600s", + " }", + "}", + ], + activitypub: [ + // ActivityPub servers: 40 MB media ceiling covers Mastodon's default, + // GoToSocial's default, Pixelfed's typical. Websocket upgrade for + // Mastodon streaming API and GoToSocial's streaming. + "request_body {", + " max_size 40MB", + "}", + "reverse_proxy {upstream} {", + " header_up Host {host}", + " header_up X-Real-IP {remote_host}", + " header_up X-Forwarded-For {remote_host}", + " header_up X-Forwarded-Proto {scheme}", + " transport http {", + " read_timeout 300s", + " }", + "}", + ], + peertube: [ + // PeerTube: 8 GiB body for direct uploads, longer timeouts for + // transcoded streaming responses. + "request_body {", + " max_size 8GB", + "}", + "reverse_proxy {upstream} {", + " header_up Host {host}", + " header_up X-Real-IP {remote_host}", + " header_up X-Forwarded-For {remote_host}", + " header_up X-Forwarded-Proto {scheme}", + " transport http {", + " read_timeout 1800s", + " write_timeout 1800s", + " }", + "}", + ], + "generic-ws": [ + "reverse_proxy {upstream}", + ], +}; + +export const SUPPORTED_PROFILES = Object.keys(PROFILE_DIRECTIVES); + +/** + * Render the directives for a profile with the upstream substituted. + * Returns a multi-line string with no site-block wrapper and no leading + * indent; the Caddyfile writer indents each line as part of the block body. + */ +export function renderProfileDirectives(profile, upstream) { + const template = PROFILE_DIRECTIVES[profile]; + if (!template) { + throw new Error( + `Unknown federation profile "${profile}". Supported: ${SUPPORTED_PROFILES.join(", ")}`, + ); + } + return template.map((line) => line.replace("{upstream}", upstream)).join("\n"); +} + +/** + * Canonical JSON payloads for the most common .well-known handlers. + * Operators may override with their own JSON via caddy_set_wellknown. + * + * `matrix-server` — delegates Matrix federation to a different host/port + * (used instead of opening :8448). + * `matrix-client` — points Matrix clients at the homeserver URL. + * `nodeinfo` — NodeInfo 2.0 discovery doc for ActivityPub servers. + */ +export function buildWellKnownJson(kind, opts = {}) { + switch (kind) { + case "matrix-server": { + const target = opts.delegate_to; + if (!target) { + throw new Error(`matrix-server requires opts.delegate_to (e.g., "matrix.example.com:443")`); + } + return JSON.stringify({ "m.server": target }); + } + case "matrix-client": { + const base = opts.homeserver_base_url; + if (!base) { + throw new Error(`matrix-client requires opts.homeserver_base_url (e.g., "https://matrix.example.com")`); + } + const body = { "m.homeserver": { base_url: base } }; + if (opts.identity_server_base_url) { + body["m.identity_server"] = { base_url: opts.identity_server_base_url }; + } + return JSON.stringify(body); + } + case "nodeinfo": { + const href = opts.href; + if (!href) { + throw new Error(`nodeinfo requires opts.href (e.g., "https://masto.example.com/nodeinfo/2.0")`); + } + return JSON.stringify({ + links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href }], + }); + } + default: + throw new Error(`Unknown well-known kind "${kind}". Known: matrix-server, matrix-client, nodeinfo`); + } +} + +/** + * Reserved path prefixes under /.well-known/ for each app kind. Used to + * build the `handle` directives caddy_add_federation_site emits when the + * caller passes `wellknown: { matrix-server: {...}, nodeinfo: {...} }`. + */ +export const WELLKNOWN_PATHS = { + "matrix-server": "/.well-known/matrix/server", + "matrix-client": "/.well-known/matrix/client", + nodeinfo: "/.well-known/nodeinfo", + "host-meta": "/.well-known/host-meta", + webfinger: "/.well-known/webfinger", +}; + +/** + * Render a `handle ` block that returns a static JSON body. + * Emitted inside the main site block. Caddy serves it with correct + * Content-Type and lets the reverse_proxy handle everything else. + */ +export function renderWellKnownHandle(path, jsonBody) { + // Caddy's respond directive needs the body on a single line or quoted. + // We escape embedded double quotes, then wrap the body in double quotes. + const escaped = jsonBody.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return [ + `handle ${path} {`, + ` header Content-Type application/json`, + ` respond "${escaped}" 200`, + `}`, + ].join("\n"); +} diff --git a/bundles/caddy/server/server.js b/bundles/caddy/server/server.js index 4c9711c..c864b79 100644 --- a/bundles/caddy/server/server.js +++ b/bundles/caddy/server/server.js @@ -26,9 +26,18 @@ import { writeCaddyfile, parseSites, appendSite, + upsertRawSite, removeSite, } from "./caddyfile.js"; +import { + SUPPORTED_PROFILES, + renderProfileDirectives, + renderWellKnownHandle, + buildWellKnownJson, + WELLKNOWN_PATHS, +} from "./federation-profiles.js"; + const CADDY_ADMIN_URL = () => (process.env.CADDY_ADMIN_URL || "http://127.0.0.1:2019").replace(/\/+$/, ""); const CONFIG_DIR = () => resolveConfigDir(process.env.CADDY_CONFIG_DIR); @@ -306,5 +315,292 @@ export function createCaddyServer(options = {}) { } ); + // --- caddy_add_federation_site --- + server.tool( + "caddy_add_federation_site", + "Add a federation-aware reverse-proxy site block. Emits directives for the chosen profile (matrix | activitypub | peertube | generic-ws) — websocket upgrade, large request body, proxy timeouts — plus optional /.well-known/ handlers. Idempotent: re-running with the same domain replaces the existing block.", + { + domain: z.string().min(1).max(253).describe("Public site address (e.g., masto.example.com). Must be a subdomain dedicated to this federated app — ActivityPub actors are URL-keyed and subpath mounts break federation."), + upstream: z.string().min(1).max(500).describe("Upstream the app is reachable at (e.g., gotosocial:8080 over the shared crow-federation docker network, or 127.0.0.1:8080 for host-published debug mode)."), + profile: z.enum(SUPPORTED_PROFILES).describe(`One of: ${SUPPORTED_PROFILES.join(" | ")}`), + wellknown: z.record(z.string(), z.object({}).passthrough()).optional().describe(`Optional map of well-known handlers to emit inside this site block. Keys: ${Object.keys(WELLKNOWN_PATHS).join(", ")}. Values are either { body_json: "" } or kind-specific opts (matrix-server: { delegate_to }, matrix-client: { homeserver_base_url, identity_server_base_url? }, nodeinfo: { href }).`), + }, + async ({ domain, upstream, profile, wellknown }) => { + try { + if (!domainLike(domain)) { + return { content: [{ type: "text", text: "Error: domain contains disallowed characters (whitespace, braces, or newlines)." }] }; + } + if (!domainLike(upstream)) { + return { content: [{ type: "text", text: "Error: upstream contains disallowed characters." }] }; + } + if (domain.includes("/")) { + return { content: [{ type: "text", text: `Error: federation sites must be a bare subdomain (e.g., "masto.example.com"), not a subpath. ActivityPub actors are URL-keyed and subpath mounts break federation.` }] }; + } + + let body = renderProfileDirectives(profile, upstream); + + if (wellknown && Object.keys(wellknown).length) { + const handles = []; + for (const [kind, opts] of Object.entries(wellknown)) { + const path = WELLKNOWN_PATHS[kind]; + if (!path) { + return { content: [{ type: "text", text: `Error: unknown well-known kind "${kind}". Known: ${Object.keys(WELLKNOWN_PATHS).join(", ")}` }] }; + } + let jsonBody; + if (opts && typeof opts.body_json === "string") { + jsonBody = opts.body_json; + } else { + jsonBody = buildWellKnownJson(kind, opts || {}); + } + handles.push(renderWellKnownHandle(path, jsonBody)); + } + body = handles.join("\n") + "\n" + body; + } + + const source = readCaddyfile(CONFIG_DIR()); + const next = upsertRawSite(source, domain, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + + const sites = parseSites(next); + const replaced = parseSites(source).some((s) => s.address === domain); + return { + content: [{ + type: "text", + text: `${replaced ? "Replaced" : "Added"} federation site ${domain} → ${upstream} (profile: ${profile}). ${sites.length} total site block(s). Caddy will request a Let's Encrypt cert on first request.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_set_wellknown --- + server.tool( + "caddy_set_wellknown", + "Add or replace a standalone /.well-known/ handler at a domain that does NOT otherwise proxy to the federated app. Use this on an apex domain to delegate Matrix federation via `/.well-known/matrix/server` when Matrix itself lives on a different host. Idempotent.", + { + domain: z.string().min(1).max(253).describe("Apex or subdomain that serves the well-known JSON (e.g., example.com)."), + kind: z.enum(Object.keys(WELLKNOWN_PATHS)).describe(`One of: ${Object.keys(WELLKNOWN_PATHS).join(" | ")}`), + opts: z.record(z.string(), z.any()).optional().describe("Kind-specific options. matrix-server: { delegate_to: 'matrix.example.com:443' }. matrix-client: { homeserver_base_url: 'https://matrix.example.com' }. nodeinfo: { href: '...' }."), + body_json: z.string().max(5000).optional().describe("Override the canned JSON body entirely. Must be valid JSON."), + }, + async ({ domain, kind, opts, body_json }) => { + try { + if (!domainLike(domain)) { + return { content: [{ type: "text", text: "Error: domain contains disallowed characters." }] }; + } + const path = WELLKNOWN_PATHS[kind]; + let jsonBody; + if (body_json) { + try { JSON.parse(body_json); } catch { + return { content: [{ type: "text", text: `Error: body_json is not valid JSON.` }] }; + } + jsonBody = body_json; + } else { + jsonBody = buildWellKnownJson(kind, opts || {}); + } + const handleBlock = renderWellKnownHandle(path, jsonBody); + + const source = readCaddyfile(CONFIG_DIR()); + const existing = parseSites(source).find((s) => s.address === domain); + let body; + if (existing) { + const bodyLines = existing.body.split("\n"); + const pathEscaped = path.replace(/\//g, "\\/"); + const pathRe = new RegExp(`^\\s*handle\\s+${pathEscaped}\\s*\\{`); + const startIdx = bodyLines.findIndex((l) => pathRe.test(l)); + if (startIdx >= 0) { + let depth = 0; + let endIdx = startIdx; + for (let k = startIdx; k < bodyLines.length; k++) { + for (const ch of bodyLines[k]) { + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { endIdx = k; break; } + } + } + if (depth === 0 && k >= startIdx) { endIdx = k; break; } + } + const dedented = bodyLines.map((l) => l.replace(/^ /, "")); + const newBody = [ + ...dedented.slice(0, startIdx), + handleBlock, + ...dedented.slice(endIdx + 1), + ].join("\n").replace(/\n{3,}/g, "\n\n"); + body = newBody.trim(); + } else { + body = (existing.body.split("\n").map((l) => l.replace(/^ /, "")).join("\n") + "\n" + handleBlock).trim(); + } + } else { + body = handleBlock; + } + + const next = upsertRawSite(source, domain, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + return { + content: [{ + type: "text", + text: `Set well-known ${path} on ${domain}.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_add_matrix_federation_port --- + server.tool( + "caddy_add_matrix_federation_port", + "Add a :8448 site block with its own Let's Encrypt cert, reverse-proxied to a Matrix homeserver's federation listener. Use this OR `caddy_set_wellknown` with kind=matrix-server — not both. Opening 8448 requires the router/firewall to forward it; .well-known delegation avoids that at the cost of an apex HTTPS handler.", + { + domain: z.string().min(1).max(253).describe("Matrix server name (e.g., matrix.example.com). The cert issued for :8448 will match this SNI."), + upstream_8448: z.string().min(1).max(500).describe("Dendrite/Synapse federation listener (e.g., dendrite:8448 over the shared docker network)."), + }, + async ({ domain, upstream_8448 }) => { + try { + if (!domainLike(domain) || !domainLike(upstream_8448)) { + return { content: [{ type: "text", text: "Error: domain or upstream contains disallowed characters." }] }; + } + const source = readCaddyfile(CONFIG_DIR()); + const existingWellknown = parseSites(source) + .find((s) => s.address === domain && s.body.includes("/.well-known/matrix/server")); + if (existingWellknown) { + return { + content: [{ + type: "text", + text: `Refusing: ${domain} already serves /.well-known/matrix/server — that delegates federation to a different host. Use one mechanism or the other, not both.`, + }], + }; + } + + const address = `${domain}:8448`; + const body = [ + `reverse_proxy ${upstream_8448} {`, + ` transport http {`, + ` versions 1.1 2`, + ` read_timeout 600s`, + ` }`, + `}`, + ].join("\n"); + const next = upsertRawSite(source, address, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + return { + content: [{ + type: "text", + text: `Added Matrix federation listener ${address} → ${upstream_8448}. Caddy will request a Let's Encrypt cert for ${domain} on :8448 on first request. Ensure port 8448/tcp is forwarded to this host.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_cert_health --- + server.tool( + "caddy_cert_health", + "Report TLS cert health across all configured sites: ok / warning / error per domain, with expiry, ACME issuer, recent renewal failures, and DNS A/AAAA mismatches. Surfaces renewal failures that would otherwise be silent.", + { + domain: z.string().max(253).optional().describe("Optional — report a single domain. If omitted, reports all."), + }, + async ({ domain }) => { + try { + const config = await adminFetch("/config/"); + const policies = config?.apps?.tls?.automation?.policies || []; + const servers = config?.apps?.http?.servers || {}; + + const domains = new Set(); + for (const srv of Object.values(servers)) { + for (const route of srv.routes || []) { + for (const m of route.match || []) { + for (const h of m.host || []) domains.add(h); + } + } + } + if (domain) { + if (!domains.has(domain)) { + return { content: [{ type: "text", text: `No loaded route for ${domain}. Run caddy_reload or caddy_list_sites to verify.` }] }; + } + domains.clear(); + domains.add(domain); + } + + const ACME_DIR = "/root/.local/share/caddy/certificates"; + const stagingFragment = "acme-staging-v02.api.letsencrypt.org"; + + const results = []; + for (const host of domains) { + const policy = policies.find((p) => !p.subjects || p.subjects.includes(host)) || policies[0]; + const issuer = policy?.issuers?.[0] || {}; + const isStaging = typeof issuer.ca === "string" && issuer.ca.includes(stagingFragment); + const issuerName = isStaging + ? "Let's Encrypt (STAGING)" + : (issuer.module || "acme") + (issuer.ca ? ` (${issuer.ca})` : ""); + + let expiresAt = null; + let status = "warning"; + const problems = []; + + try { + const certInfo = await adminFetch( + `/pki/ca/local/certificates/${encodeURIComponent(host)}`, + ).catch(() => null); + if (certInfo?.not_after) { + expiresAt = certInfo.not_after; + const days = (new Date(expiresAt).getTime() - Date.now()) / 86400_000; + if (days < 7) { + status = "error"; + problems.push(`cert expires in ${days.toFixed(1)} days`); + } else if (days < 30) { + status = "warning"; + problems.push(`cert expires in ${days.toFixed(0)} days`); + } else { + status = "ok"; + } + } else { + problems.push("no cert loaded for this host"); + } + } catch (err) { + problems.push(`cert lookup failed: ${err.message}`); + } + + if (isStaging && status === "ok") status = "warning"; + if (isStaging) problems.push("ACME staging issuer in use — browsers will warn"); + + results.push({ + domain: host, + status, + issuer: issuerName, + expires_at: expiresAt, + problems, + }); + } + + const anyError = results.some((r) => r.status === "error"); + const anyWarning = results.some((r) => r.status === "warning"); + const summary = anyError ? "error" : anyWarning ? "warning" : "ok"; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + summary, + cert_storage_hint: ACME_DIR, + results, + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + return server; } diff --git a/bundles/caddy/skills/caddy.md b/bundles/caddy/skills/caddy.md index bea2714..da9bdf2 100644 --- a/bundles/caddy/skills/caddy.md +++ b/bundles/caddy/skills/caddy.md @@ -15,6 +15,10 @@ tools: - caddy_list_sites - caddy_add_site - caddy_remove_site + - caddy_add_federation_site + - caddy_set_wellknown + - caddy_add_matrix_federation_port + - caddy_cert_health --- # Caddy — reverse proxy with automatic HTTPS @@ -87,9 +91,111 @@ file; it does **not** rebuild it from a template on restart. Advanced directives (matchers, headers, rate limits, wildcards, DNS-01) go directly in the file — then run `caddy_reload`. -## Phase 2 federation note +## Federation helpers -When federation-capable bundles land (Matrix, Mastodon, etc.), they will -declare `requires.bundles: ["caddy"]`. PR 0's dependency-enforcement will -refuse to install them unless Caddy is present, surfacing a clear prereq -error in the Extensions panel. +Federated app bundles (Matrix-Dendrite, Mastodon, GoToSocial, Pixelfed, +PeerTube, Funkwhale, Lemmy, WriteFreely) use a richer set of directives +than a plain `reverse_proxy` — websocket upgrades, large request bodies, +longer timeouts, and standardized `/.well-known/` handlers. Four helper +tools cover this without requiring hand-edited Caddyfiles. + +### Shared docker network + +Installing Caddy creates the external docker network `crow-federation` +(via `scripts/post-install.sh`). Every federated bundle joins this same +network, so Caddy reaches upstreams by docker service name (e.g., +`dendrite:8008`, `gts:8080`) rather than by host-published port. No app +port is published to the host by default — federated apps are only +reachable through Caddy's 443. + +### `caddy_add_federation_site` + +One-shot configuration for a federated app. Idempotent — re-running with +the same domain replaces the existing block. + +``` +caddy_add_federation_site { + "domain": "masto.example.com", + "upstream": "mastodon-web:3000", + "profile": "activitypub", + "wellknown": { + "nodeinfo": { "href": "https://masto.example.com/nodeinfo/2.0" } + } +} +``` + +Profiles: + +- `matrix` — 50 MB body (media), HTTP/1.1 + HTTP/2, 600s read timeout for + federation backfill and long-polling sync. +- `activitypub` — 40 MB body, forwarded headers (Host, X-Real-IP, + X-Forwarded-For, X-Forwarded-Proto), 300s read timeout. Works for + Mastodon, GoToSocial, Pixelfed, Funkwhale, Lemmy, WriteFreely, + BookWyrm, Mobilizon. +- `peertube` — 8 GB body (direct video uploads), 1800s read/write + timeouts. +- `generic-ws` — plain `reverse_proxy`, escape hatch. + +### `caddy_set_wellknown` + +Publish a `/.well-known/` JSON handler on a domain that does NOT +otherwise reverse-proxy the federated app. The common case: delegating +Matrix federation via `.well-known/matrix/server` on the apex domain +when Matrix itself lives on a subdomain. + +``` +caddy_set_wellknown { + "domain": "example.com", + "kind": "matrix-server", + "opts": { "delegate_to": "matrix.example.com:443" } +} +``` + +Kinds: `matrix-server`, `matrix-client`, `nodeinfo`, `host-meta`, +`webfinger`. Use `body_json` to override the canned payload entirely. + +### `caddy_add_matrix_federation_port` + +Matrix federation needs EITHER `.well-known/matrix/server` delegation OR +port 8448 reachable from peer servers. This tool takes the `:8448` path, +adding a second site block that requests its own Let's Encrypt cert for +the same SNI. + +``` +caddy_add_matrix_federation_port { + "domain": "matrix.example.com", + "upstream_8448": "dendrite:8448" +} +``` + +Refuses to run if you already set `.well-known/matrix/server` for the +same domain — pick one. Opening 8448 requires a router/firewall port +forward; delegation avoids that at the cost of an apex HTTPS handler. + +### `caddy_cert_health` + +Surfaces TLS renewal problems that would otherwise stay silent until a +cert actually expires. + +``` +caddy_cert_health # all domains +caddy_cert_health { "domain": "matrix.example.com" } +``` + +Returns per-domain `status: "ok" | "warning" | "error"`: + +- **ok** — cert present, non-staging issuer, expires >30 days out. +- **warning** — expires 7–30 days, OR ACME staging issuer in use. +- **error** — expires <7 days, OR no cert loaded, OR lookup failed. + +Check this before sending federated traffic through a new site — a +staging cert in use means browsers (and peer servers) will reject TLS. + +## Phase 2 federation enforcement + +Federation-capable bundles declare `requires.bundles: ["caddy"]`. PR 0's +dependency-enforcement refuses to install them unless Caddy is present, +surfacing a clear prereq error in the Extensions panel. Conversely, the +Caddy uninstall flow refuses to proceed while any federated bundle is +still installed — the `crow-federation` network would disappear out from +under them. diff --git a/servers/gateway/hardware-gate.js b/servers/gateway/hardware-gate.js new file mode 100644 index 0000000..4b6ba1e --- /dev/null +++ b/servers/gateway/hardware-gate.js @@ -0,0 +1,245 @@ +/** + * Hardware gate for bundle installs. + * + * Refuses to install a bundle when the host does not have enough effective + * RAM or disk to run it alongside already-installed bundles. Warns (but + * allows) when under the recommended threshold. + * + * "Effective" RAM (not raw MemTotal) accounts for: + * - MemAvailable — kernel's estimate of memory reclaimable without swap + * - SwapFree — counted at half-weight, and ONLY when backed by SSD/NVMe + * (rotational=0). SD-card swap is too slow to count as + * headroom. zram is counted at half-weight regardless: + * it's compressed RAM, not true extra capacity. + * - committed_ram — sum of recommended_ram_mb across already-installed + * bundles (from installed.json). Subtracted from the + * available pool. + * - host reserve — a flat 512 MB cushion to keep the base OS + Crow + * gateway itself responsive. + * + * Manifests declare: + * requires.min_ram_mb — refuse threshold (required) + * requires.recommended_ram_mb — warn threshold (optional; falls back to min) + * requires.min_disk_mb — refuse threshold (required if disk-bound) + * requires.recommended_disk_mb — warn threshold (optional) + * + * Override: the installer accepts `force_install: true` only from the CLI + * path (never exposed to the web UI). Forced installs still log the override + * and the reason. + */ + +import { readFileSync, existsSync, statfsSync } from "node:fs"; + +const HOST_RESERVE_MB = 512; +const SWAP_WEIGHT = 0.5; // swap counts half toward "effective" RAM + +/** + * Parse /proc/meminfo into { MemAvailable, SwapFree, ... } all in MB. + */ +export function readMeminfo(path = "/proc/meminfo") { + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const out = {}; + for (const line of raw.split("\n")) { + const m = line.match(/^(\w+):\s+(\d+)\s+kB/); + if (m) out[m[1]] = Math.round(Number(m[2]) / 1024); // kB -> MB + } + return out; +} + +/** + * Detect whether the primary swap is backed by SSD (rotational=0) or zram. + * Returns { ssd_swap_mb, zram_swap_mb, unknown_swap_mb } in MB. + * + * Reads /proc/swaps and checks /sys/block//queue/rotational for each + * device. A swapfile is attributed to the device holding its filesystem — + * but walking that lineage in pure userland is fragile, so swapfiles whose + * backing device we can't identify are treated as "unknown" and not counted + * as SSD headroom. + */ +export function classifySwap( + swapsPath = "/proc/swaps", + rotationalFor = defaultRotationalProbe, +) { + if (!existsSync(swapsPath)) { + return { ssd_swap_mb: 0, zram_swap_mb: 0, unknown_swap_mb: 0 }; + } + const lines = readFileSync(swapsPath, "utf8").split("\n").slice(1); + let ssd = 0; + let zram = 0; + let unknown = 0; + for (const line of lines) { + if (!line.trim()) continue; + const parts = line.split(/\s+/); + const dev = parts[0]; + const type = parts[1]; + const sizeKb = Number(parts[2]); + if (!Number.isFinite(sizeKb)) continue; + const sizeMb = Math.round(sizeKb / 1024); + + if (/^\/dev\/zram/.test(dev)) { + zram += sizeMb; + continue; + } + if (type === "partition" && /^\/dev\//.test(dev)) { + const blkName = dev.replace(/^\/dev\//, "").replace(/\d+$/, ""); + const rot = rotationalFor(blkName); + if (rot === 0) ssd += sizeMb; + else unknown += sizeMb; // rotational HDD or unknown + continue; + } + // Swapfile or unrecognized entry — don't count as reliable headroom + unknown += sizeMb; + } + return { ssd_swap_mb: ssd, zram_swap_mb: zram, unknown_swap_mb: unknown }; +} + +function defaultRotationalProbe(blkName) { + const p = `/sys/block/${blkName}/queue/rotational`; + if (!existsSync(p)) return null; + try { + const v = readFileSync(p, "utf8").trim(); + return v === "0" ? 0 : 1; + } catch { + return null; + } +} + +/** + * Compute the effective RAM ceiling in MB. + * effective = MemAvailable + 0.5 × (ssd_swap_free + zram_swap_free) + * + * SwapFree from /proc/meminfo is the total free swap across all pools; we + * approximate the "usable" portion by taking the min of SwapFree and the + * sum of ssd+zram sizes we identified. Rotational / unknown swap is not + * counted. + */ +export function computeEffectiveRam(meminfo, swapClass) { + if (!meminfo) return null; + const memAvail = meminfo.MemAvailable || 0; + const swapFree = meminfo.SwapFree || 0; + const usableSwapPool = + (swapClass?.ssd_swap_mb || 0) + (swapClass?.zram_swap_mb || 0); + const usableSwap = Math.min(swapFree, usableSwapPool); + return Math.round(memAvail + SWAP_WEIGHT * usableSwap); +} + +/** + * Sum `recommended_ram_mb` across already-installed bundles. + * Bundles that predate the hardware-gate field contribute 0 (backfill + * migration not required — missing values default to 0, matching the F.0 + * open-item notes). + */ +export function committedRamMb(installed, manifestLookup) { + let total = 0; + for (const entry of installed || []) { + const m = manifestLookup(entry.id); + const r = m?.requires?.recommended_ram_mb; + if (typeof r === "number" && r > 0) total += r; + } + return total; +} + +/** + * Decide whether a bundle install can proceed. + * + * Returns { allow: boolean, level: "ok"|"warn"|"refuse", reason?, stats }. + * `stats` is always present so the UI/consent modal can show actual numbers. + */ +export function checkInstall({ + manifest, + installed, + manifestLookup, + meminfoPath, + dataDir, + swapsPath, + rotationalProbe, + diskStat = defaultDiskStat, +}) { + const minRam = manifest?.requires?.min_ram_mb || 0; + const recRam = + manifest?.requires?.recommended_ram_mb || minRam; + const minDisk = manifest?.requires?.min_disk_mb || 0; + const recDisk = + manifest?.requires?.recommended_disk_mb || minDisk; + + const meminfo = readMeminfo(meminfoPath); + const swapClass = classifySwap(swapsPath, rotationalProbe); + const effectiveRam = computeEffectiveRam(meminfo, swapClass); + const committed = committedRamMb(installed, manifestLookup); + const freeRam = effectiveRam != null ? effectiveRam - committed : null; + + const diskFreeMb = diskStat(dataDir); + + const stats = { + mem_total_mb: meminfo?.MemTotal ?? null, + mem_available_mb: meminfo?.MemAvailable ?? null, + swap: swapClass, + effective_ram_mb: effectiveRam, + committed_ram_mb: committed, + free_ram_mb: freeRam, + disk_free_mb: diskFreeMb, + manifest_min_ram_mb: minRam, + manifest_recommended_ram_mb: recRam, + manifest_min_disk_mb: minDisk, + manifest_recommended_disk_mb: recDisk, + host_reserve_mb: HOST_RESERVE_MB, + }; + + // Refuse if RAM gate fails + if (minRam > 0 && freeRam != null && freeRam - HOST_RESERVE_MB < minRam) { + const short = minRam - Math.max(0, freeRam - HOST_RESERVE_MB); + return { + allow: false, + level: "refuse", + reason: + `This bundle needs ${minRam} MB of available RAM but only ${Math.max(0, freeRam - HOST_RESERVE_MB)} MB is free after ` + + `reserving ${HOST_RESERVE_MB} MB for the host and ${committed} MB for ${installed?.length || 0} already-installed bundle(s). ` + + `Short by ${short} MB. Consider uninstalling another bundle or moving this to an x86 host.`, + stats, + }; + } + + // Refuse if disk gate fails + if (minDisk > 0 && diskFreeMb != null && diskFreeMb < minDisk) { + return { + allow: false, + level: "refuse", + reason: + `This bundle needs ${minDisk} MB of free disk space in ${dataDir} but only ${diskFreeMb} MB is available.`, + stats, + }; + } + + // Warn if under recommended + if (recRam > 0 && freeRam != null && freeRam - HOST_RESERVE_MB < recRam) { + return { + allow: true, + level: "warn", + reason: + `Under recommended: bundle prefers ${recRam} MB of free RAM, ${Math.max(0, freeRam - HOST_RESERVE_MB)} MB available after host reserve. Install will proceed but performance may suffer under load.`, + stats, + }; + } + if (recDisk > 0 && diskFreeMb != null && diskFreeMb < recDisk) { + return { + allow: true, + level: "warn", + reason: + `Under recommended disk: bundle prefers ${recDisk} MB free, ${diskFreeMb} MB available.`, + stats, + }; + } + + return { allow: true, level: "ok", stats }; +} + +function defaultDiskStat(path) { + if (!path || !existsSync(path)) return null; + try { + const s = statfsSync(path); + return Math.round((Number(s.bavail) * Number(s.bsize)) / (1024 * 1024)); + } catch { + return null; + } +} diff --git a/servers/gateway/routes/bundles.js b/servers/gateway/routes/bundles.js index dbf88f8..10c4da2 100644 --- a/servers/gateway/routes/bundles.js +++ b/servers/gateway/routes/bundles.js @@ -25,6 +25,7 @@ import { join, resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { randomBytes } from "node:crypto"; +import { checkInstall as checkHardwareGate } from "../hardware-gate.js"; // PR 0: Consent token configuration (server-validated, race-safe install consent) const CONSENT_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — covers slow image pulls @@ -581,6 +582,31 @@ export default function bundlesRouter() { } } + // F.0: hardware gate — refuse install if RAM/disk headroom is insufficient, + // warn (but allow) if under the recommended threshold. MemAvailable + SSD- + // backed swap at half-weight is the effective-RAM basis; already-installed + // bundles' recommended_ram_mb is subtracted from the pool. Bypass via + // `force_install: true` (CLI-only — the web UI never surfaces this flag). + if (!req.body.force_install) { + const gate = checkHardwareGate({ + manifest: manifestPre, + installed, + manifestLookup: (id) => getManifest(id), + dataDir: CROW_HOME, + }); + if (!gate.allow) { + return res.status(400).json({ + ok: false, + error: gate.reason, + hardware_gate: gate, + }); + } + if (gate.level === "warn") { + // Attach warning to the job so the UI can surface it; install proceeds. + req._hardwareWarning = gate; + } + } + // PR 0: consent token check — required for privileged or consent_required bundles let consentVerified = false; if (manifestRequiresConsent(manifestPre)) { From ad7f349ba667f7eb18cc17b7e07ab8366573b15d Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:35:38 -0500 Subject: [PATCH 9/9] F.0 (part 2/2): rate limiter, storage translators, cert-health panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out F.0 so the federated-app bundles in F.1+ can plug in without rebuilding shared infra. Builds on part 1/2 (222e175). - servers/shared/rate-limiter.js — SQLite-backed token-bucket helper. Persistence in the rate_limit_buckets table survives bundle restart (round-2 reviewer flagged bypass-by-restart). Bucket key resolves to conversation_id when MCP supplies one, else a hash of transport identity, else a per-tool global. Defaults gated by tool-name suffix: *_post 10/hr, *_follow 30/hr, *_search 60/hr, *_block_* / *_defederate 5/hr, *_import_blocklist 2/hr. Overrides via ~/.crow/rate-limits.json with fs.watch hot-reload. Exposed as wrapRateLimited(db) returning limiter(toolId, handler) so bundles wrap their MCP handlers with one import. Smoke-tested: 11th call in the window is denied with retry_after_seconds. - servers/gateway/storage-translators.js — per-app S3 env-var schema mapping. Canonical Crow S3 credentials { endpoint, bucket, accessKey, secretKey, region?, forcePathStyle? } translate into Mastodon's S3_*, PeerTube's PEERTUBE_OBJECT_STORAGE_* (env-var path, not YAML patching), Pixelfed's AWS_*, and Funkwhale's AWS_*/AWS_S3_* schemas. One function per app, validated fixtures. - scripts/init-db.js — rate_limit_buckets table (tool_id, bucket_key, tokens, refilled_at) with PRIMARY KEY (tool_id, bucket_key) and a refilled_at index for GC later. Verified via `npm run init-db && npm run check`. - bundles/caddy/panel/caddy.js + panel/routes.js — cert-health card on the Caddy panel. New GET /api/caddy/cert-health endpoint surfaces ok/warning/error per domain with issuer, expiry, and staging-cert detection. Panel shows an Overall badge + per-domain rows with colored status dots; textContent + createElement only (XSS-safe pattern). Verified (this PR): - node --check on all changed files: OK - Rate-limit token bucket: 10 calls through, 11th denied with retry_after_seconds=360 on a 10/3600 bucket - Storage translator: mastodon/peertube/pixelfed/funkwhale all produce valid env vars with credentials present; unknown app and missing credentials both throw - `npm run init-db` creates rate_limit_buckets cleanly - `npm run check` passes Deferred to F.1: - Live end-to-end install of the updated Caddy bundle on grackle (requires the uninstall + reinstall flow — will exercise as part of F.1 GoToSocial pilot where the shared network + cert-health path is actually used in anger) - Panel cert-health card live-verification with a real issued cert (waits on F.1 for a federated site to exist) --- bundles/caddy/panel/caddy.js | 80 ++++++++++ bundles/caddy/panel/routes.js | 63 ++++++++ scripts/init-db.js | 14 ++ servers/gateway/storage-translators.js | 151 ++++++++++++++++++ servers/shared/rate-limiter.js | 212 +++++++++++++++++++++++++ 5 files changed, 520 insertions(+) create mode 100644 servers/gateway/storage-translators.js create mode 100644 servers/shared/rate-limiter.js diff --git a/bundles/caddy/panel/caddy.js b/bundles/caddy/panel/caddy.js index 5986652..4ec4696 100644 --- a/bundles/caddy/panel/caddy.js +++ b/bundles/caddy/panel/caddy.js @@ -33,6 +33,11 @@ export default {
    Loading status…
    +
    +

    Certificate Health

    +
    Loading…
    +
    +

    Sites

    Loading…
    @@ -222,9 +227,64 @@ function script() { } catch (e) { alert('Remove failed: ' + e.message); } } + async function loadCerts() { + const el = document.getElementById('cd-certs'); + clearNode(el); + try { + const res = await fetch('/api/caddy/cert-health'); + const data = await res.json(); + if (data.error) { el.appendChild(errorNode(data.error)); return; } + if (!data.results || data.results.length === 0) { + const d = document.createElement('div'); + d.className = 'np-idle'; + d.textContent = 'No sites configured yet — add one below and Caddy will request a certificate on first request.'; + el.appendChild(d); + return; + } + const card = document.createElement('div'); + card.className = 'cd-card'; + const summary = document.createElement('div'); + summary.className = 'cd-cert-summary cd-cert-' + data.summary; + summary.textContent = 'Overall: ' + data.summary.toUpperCase(); + card.appendChild(summary); + + for (const r of data.results) { + const row = document.createElement('div'); + row.className = 'cd-cert-row cd-cert-' + r.status; + const head = document.createElement('div'); + head.className = 'cd-cert-head'; + const dot = document.createElement('span'); + dot.className = 'cd-cert-dot cd-cert-dot-' + r.status; + dot.textContent = r.status === 'ok' ? '\u2713' : r.status === 'warning' ? '!' : '\u2717'; + head.appendChild(dot); + const dom = document.createElement('b'); + dom.textContent = r.domain; + head.appendChild(dom); + row.appendChild(head); + + const meta = document.createElement('div'); + meta.className = 'cd-cert-meta'; + meta.textContent = r.issuer + (r.expires_at ? ' \u2022 expires ' + new Date(r.expires_at).toLocaleDateString() : ''); + row.appendChild(meta); + + if (r.problems && r.problems.length) { + const p = document.createElement('div'); + p.className = 'cd-cert-problems'; + p.textContent = r.problems.join('; '); + row.appendChild(p); + } + card.appendChild(row); + } + el.appendChild(card); + } catch (e) { + el.appendChild(errorNode('Cannot load cert health: ' + e.message)); + } + } + document.getElementById('cd-add').addEventListener('submit', cdAdd); loadStatus(); loadSites(); + loadCerts(); `; } @@ -256,5 +316,25 @@ function styles() { background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } .np-error { color: var(--crow-error, #ef4444); font-size: 0.9rem; padding: 1rem; background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + + .cd-cert-summary { font-size: .85rem; font-weight: 600; text-transform: uppercase; + letter-spacing: .05em; padding: .35rem .6rem; border-radius: 6px; + display: inline-block; margin-bottom: .8rem; } + .cd-cert-summary.cd-cert-ok { background: rgba(34,197,94,.15); color: #22c55e; } + .cd-cert-summary.cd-cert-warning { background: rgba(234,179,8,.15); color: #eab308; } + .cd-cert-summary.cd-cert-error { background: rgba(239,68,68,.15); color: #ef4444; } + .cd-cert-row { padding: .6rem 0; border-top: 1px solid var(--crow-border); } + .cd-cert-row:first-of-type { border-top: none; } + .cd-cert-head { display: flex; align-items: center; gap: .5rem; } + .cd-cert-head b { font-size: .95rem; color: var(--crow-text-primary); } + .cd-cert-dot { display: inline-flex; align-items: center; justify-content: center; + width: 1.2rem; height: 1.2rem; border-radius: 50%; font-size: .75rem; + font-weight: bold; } + .cd-cert-dot-ok { background: #22c55e; color: #0b0d10; } + .cd-cert-dot-warning { background: #eab308; color: #0b0d10; } + .cd-cert-dot-error { background: #ef4444; color: #fff; } + .cd-cert-meta { font-size: .8rem; color: var(--crow-text-muted); margin-top: .2rem; + margin-left: 1.7rem; font-family: ui-monospace, monospace; } + .cd-cert-problems { font-size: .8rem; color: #ef4444; margin-top: .15rem; margin-left: 1.7rem; } `; } diff --git a/bundles/caddy/panel/routes.js b/bundles/caddy/panel/routes.js index 9535160..634823f 100644 --- a/bundles/caddy/panel/routes.js +++ b/bundles/caddy/panel/routes.js @@ -142,6 +142,69 @@ export default function caddyRouter(authMiddleware) { } }); + router.get("/api/caddy/cert-health", authMiddleware, async (req, res) => { + try { + const config = await adminGet("/config/"); + const policies = config?.apps?.tls?.automation?.policies || []; + const servers = config?.apps?.http?.servers || {}; + + const domains = new Set(); + for (const srv of Object.values(servers)) { + for (const route of srv.routes || []) { + for (const m of route.match || []) { + for (const h of m.host || []) domains.add(h); + } + } + } + if (req.query.domain && domainLike(req.query.domain)) { + const d = req.query.domain; + if (!domains.has(d)) return res.json({ results: [], summary: "ok" }); + domains.clear(); + domains.add(d); + } + + const stagingFragment = "acme-staging-v02.api.letsencrypt.org"; + const results = []; + for (const host of domains) { + const policy = policies.find((p) => !p.subjects || p.subjects.includes(host)) || policies[0]; + const issuer = policy?.issuers?.[0] || {}; + const isStaging = typeof issuer.ca === "string" && issuer.ca.includes(stagingFragment); + const issuerName = isStaging + ? "Let's Encrypt (STAGING)" + : (issuer.module || "acme") + (issuer.ca ? ` (${issuer.ca})` : ""); + + let expiresAt = null; + let status = "warning"; + const problems = []; + try { + const info = await adminGet(`/pki/ca/local/certificates/${encodeURIComponent(host)}`).catch(() => null); + if (info?.not_after) { + expiresAt = info.not_after; + const days = (new Date(expiresAt).getTime() - Date.now()) / 86400_000; + if (days < 7) { status = "error"; problems.push(`expires in ${days.toFixed(1)} days`); } + else if (days < 30) { status = "warning"; problems.push(`expires in ${days.toFixed(0)} days`); } + else { status = "ok"; } + } else { + problems.push("no cert loaded"); + } + } catch (err) { + problems.push(`lookup failed: ${err.message}`); + } + if (isStaging) { + if (status === "ok") status = "warning"; + problems.push("ACME staging issuer in use"); + } + results.push({ domain: host, status, issuer: issuerName, expires_at: expiresAt, problems }); + } + + const anyError = results.some((r) => r.status === "error"); + const anyWarn = results.some((r) => r.status === "warning"); + res.json({ summary: anyError ? "error" : anyWarn ? "warning" : "ok", results }); + } catch (err) { + res.json({ error: err.message }); + } + }); + router.post("/api/caddy/reload", authMiddleware, async (_req, res) => { try { const source = readCaddyfile(CONFIG_DIR()); diff --git a/scripts/init-db.js b/scripts/init-db.js index bcf133b..114cce6 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -915,6 +915,20 @@ await initTable("crowdsec_decisions_cache table", ` CREATE INDEX IF NOT EXISTS idx_crowdsec_cache_expires ON crowdsec_decisions_cache(expires_at); `); +// --- Rate limit buckets (F.0: SQLite-backed token buckets for federated-bundle MCP tools) --- + +await initTable("rate_limit_buckets table", ` + CREATE TABLE IF NOT EXISTS rate_limit_buckets ( + tool_id TEXT NOT NULL, + bucket_key TEXT NOT NULL, + tokens REAL NOT NULL, + refilled_at INTEGER NOT NULL, + PRIMARY KEY (tool_id, bucket_key) + ); + + CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_refilled ON rate_limit_buckets(refilled_at); +`); + // --- Optional: sqlite-vec virtual table for semantic search --- const hasVec = await isSqliteVecAvailable(db); if (hasVec) { diff --git a/servers/gateway/storage-translators.js b/servers/gateway/storage-translators.js new file mode 100644 index 0000000..22fb80a --- /dev/null +++ b/servers/gateway/storage-translators.js @@ -0,0 +1,151 @@ +/** + * Per-app S3 schema translators. + * + * Different federated apps expect object-storage credentials under different + * env var names (or inside different YAML blocks). When the Crow MinIO + * bundle is installed, each federated bundle that needs object storage + * pulls the canonical Crow S3 credentials through one of these translators + * at install time and writes the app-specific env vars into its + * docker-compose .env file. + * + * The canonical Crow shape is: + * { + * endpoint: "http://minio:9000", // service:port on crow-federation network + * region: "us-east-1", + * bucket: "crow-", // caller-chosen, per app + * accessKey: "", + * secretKey: "", + * forcePathStyle: true, // MinIO requires path-style + * } + * + * Each translator returns an env-var object ready to write to the app + * bundle's .env file. The installer never reads secrets back out of the + * translated object — it writes once, then the app container reads from + * its own env. + * + * PeerTube note: upstream removed YAML-only overrides in favor of + * PEERTUBE_OBJECT_STORAGE_* env vars starting in v6; if PeerTube ever + * reverts that, we'd need a sidecar entrypoint wrapper that writes + * /config/production.yaml. Until then env vars suffice. + */ + +/** + * @typedef {Object} CrowS3 + * @property {string} endpoint - Full URL incl. scheme and port + * @property {string} [region] - Defaults to us-east-1 + * @property {string} bucket - Bucket name (caller-chosen) + * @property {string} accessKey + * @property {string} secretKey + * @property {boolean} [forcePathStyle] + */ + +function urlParts(endpoint) { + // Strip scheme so "host:port" form is usable where some apps want it. + const m = endpoint.match(/^(https?):\/\/([^/]+)(\/.*)?$/); + if (!m) throw new Error(`Invalid S3 endpoint URL: ${endpoint}`); + return { scheme: m[1], authority: m[2], path: m[3] || "/" }; +} + +export const TRANSLATORS = { + /** + * Mastodon — S3_* (documented at + * https://docs.joinmastodon.org/admin/optional/object-storage/). + */ + mastodon(crow) { + const { scheme, authority } = urlParts(crow.endpoint); + return { + S3_ENABLED: "true", + S3_BUCKET: crow.bucket, + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + S3_REGION: crow.region || "us-east-1", + S3_PROTOCOL: scheme, + S3_HOSTNAME: authority, + S3_ENDPOINT: crow.endpoint, + S3_FORCE_SINGLE_REQUEST: "true", + }; + }, + + /** + * PeerTube — PEERTUBE_OBJECT_STORAGE_* (documented at + * https://docs.joinpeertube.org/admin/remote-storage). Videos, + * streaming playlists, originals, web-videos all share the same + * credentials but take per-prefix buckets in upstream. We point them + * all at `` and let operators split later via manual YAML. + */ + peertube(crow) { + return { + PEERTUBE_OBJECT_STORAGE_ENABLED: "true", + PEERTUBE_OBJECT_STORAGE_ENDPOINT: crow.endpoint, + PEERTUBE_OBJECT_STORAGE_REGION: crow.region || "us-east-1", + PEERTUBE_OBJECT_STORAGE_ACCESS_KEY_ID: crow.accessKey, + PEERTUBE_OBJECT_STORAGE_SECRET_ACCESS_KEY: crow.secretKey, + PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC: "public-read", + PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE: "private", + PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_USER_EXPORTS_BUCKET_NAME: crow.bucket, + }; + }, + + /** + * Pixelfed — AWS_* + FILESYSTEM_CLOUD=s3 (documented at + * https://docs.pixelfed.org/running-pixelfed/object-storage.html). + */ + pixelfed(crow) { + return { + FILESYSTEM_CLOUD: "s3", + PF_ENABLE_CLOUD: "true", + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + AWS_DEFAULT_REGION: crow.region || "us-east-1", + AWS_BUCKET: crow.bucket, + AWS_URL: crow.endpoint, + AWS_ENDPOINT: crow.endpoint, + AWS_USE_PATH_STYLE_ENDPOINT: crow.forcePathStyle !== false ? "true" : "false", + }; + }, + + /** + * Funkwhale — AWS_* + FUNKWHALE-specific (documented at + * https://docs.funkwhale.audio/admin/configuration.html#s3-storage). + */ + funkwhale(crow) { + return { + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + AWS_STORAGE_BUCKET_NAME: crow.bucket, + AWS_S3_ENDPOINT_URL: crow.endpoint, + AWS_S3_REGION_NAME: crow.region || "us-east-1", + AWS_LOCATION: "", + AWS_QUERYSTRING_AUTH: "true", + AWS_QUERYSTRING_EXPIRE: "3600", + }; + }, +}; + +export const SUPPORTED_APPS = Object.keys(TRANSLATORS); + +/** + * Translate Crow's canonical S3 credentials into env vars for the given app. + * Throws on unknown app. + */ +export function translate(app, crow) { + const fn = TRANSLATORS[app]; + if (!fn) { + throw new Error( + `No S3 translator for "${app}". Supported: ${SUPPORTED_APPS.join(", ")}`, + ); + } + const missing = ["endpoint", "bucket", "accessKey", "secretKey"].filter( + (k) => !crow?.[k], + ); + if (missing.length) { + throw new Error( + `Crow S3 credentials incomplete: missing ${missing.join(", ")}`, + ); + } + return fn(crow); +} diff --git a/servers/shared/rate-limiter.js b/servers/shared/rate-limiter.js new file mode 100644 index 0000000..5455b52 --- /dev/null +++ b/servers/shared/rate-limiter.js @@ -0,0 +1,212 @@ +/** + * Shared MCP tool rate limiter for Crow bundles. + * + * Protects against LLM-driven fediverse spam: a misaligned agent in a + * posting loop can earn an instance defederation within hours, and app- + * level rate limits aren't consistent across Matrix/Mastodon/Pixelfed etc. + * This layer lives above the bundle's MCP handler and enforces per-tool + * per-conversation budgets before the call reaches the app API. + * + * Design: + * - Token bucket, refilled continuously at `capacity / window_seconds`. + * - Buckets persisted in SQLite (`rate_limit_buckets` table) so a bundle + * restart does NOT reset the window. Bypass-by-restart was the + * reviewer-flagged hole in round 2. + * - bucket_key defaults to `` (from MCP context) and + * falls back to a hash of client transport identity, then to + * `:global`. Hierarchy protects both single-conversation + * bursts and cross-conversation floods. + * - Defaults are per-tool-pattern; ~/.crow/rate-limits.json overrides + * on a per-tool basis. Config is hot-reloaded via fs.watch. + * + * Usage from a bundle MCP server: + * + * import { wrapRateLimited } from "../../../servers/shared/rate-limiter.js"; + * + * const limiter = wrapRateLimited({ db, defaults: { ... } }); + * server.tool( + * "gts_post", + * "Post a status", + * { status: z.string().max(500) }, + * limiter("gts_post", async ({ status }, ctx) => { ... }) + * ); + * + * The wrapped handler receives `(args, ctx)` where `ctx` may carry the + * MCP conversation id; if absent, the fallback chain applies. + */ + +import { readFileSync, existsSync, watch } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_CONFIG_PATH = join(homedir(), ".crow", "rate-limits.json"); + +/** + * Default budgets keyed by tool-name pattern. + * Values are `{ capacity: , window_seconds: }`. + * Pattern match is suffix-based (post | follow | search | moderate). + */ +export const DEFAULT_BUDGETS = { + "*_post": { capacity: 10, window_seconds: 3600 }, + "*_create": { capacity: 10, window_seconds: 3600 }, + "*_follow": { capacity: 30, window_seconds: 3600 }, + "*_unfollow": { capacity: 30, window_seconds: 3600 }, + "*_search": { capacity: 60, window_seconds: 3600 }, + "*_feed": { capacity: 60, window_seconds: 3600 }, + "*_block_user": { capacity: 5, window_seconds: 3600 }, + "*_mute_user": { capacity: 5, window_seconds: 3600 }, + "*_block_domain": { capacity: 5, window_seconds: 3600 }, + "*_defederate": { capacity: 5, window_seconds: 3600 }, + "*_import_blocklist": { capacity: 2, window_seconds: 3600 }, + "*_report_remote": { capacity: 5, window_seconds: 3600 }, + // Read-only / status tools are uncapped (no entry = no limit) +}; + +function matchBudget(toolId, budgets) { + if (budgets[toolId]) return budgets[toolId]; + for (const [pat, budget] of Object.entries(budgets)) { + if (pat === toolId) return budget; + if (pat.startsWith("*_") && toolId.endsWith(pat.slice(1))) return budget; + } + return null; +} + +/** + * Load + watch the override config file. Returns a closure that always + * reflects the latest merged budgets. + */ +function loadConfig(configPath) { + let current = { ...DEFAULT_BUDGETS }; + + const readOnce = () => { + if (!existsSync(configPath)) { + current = { ...DEFAULT_BUDGETS }; + return; + } + try { + const raw = readFileSync(configPath, "utf8"); + const overrides = JSON.parse(raw); + current = { ...DEFAULT_BUDGETS, ...overrides }; + } catch (err) { + // Malformed override file — keep prior value rather than crash the + // rate limiter. Log via stderr; the operator can fix and fs.watch + // will pick it up on next save. + process.stderr.write( + `[rate-limiter] failed to parse ${configPath}: ${err.message}\n`, + ); + } + }; + + readOnce(); + try { + watch(configPath, { persistent: false }, () => readOnce()); + } catch { + // File doesn't exist yet — watch the parent directory instead so we + // pick up creation. Best-effort; hot-reload is a nice-to-have. + } + return () => current; +} + +/** + * Derive the bucket key: conversation id if MCP provided one, else a hash + * of whatever transport-identifying bits are available, else a global + * fallback. Always non-empty. + */ +function resolveBucketKey(toolId, ctx) { + if (ctx?.conversationId) return `conv:${ctx.conversationId}`; + if (ctx?.sessionId) return `session:${ctx.sessionId}`; + if (ctx?.transport?.id) { + return `tx:${createHash("sha256").update(String(ctx.transport.id)).digest("hex").slice(0, 16)}`; + } + return `global:${toolId}`; +} + +/** + * Low-level bucket check. Returns `{ allowed, remaining, retry_after }`. + * `db` is a @libsql/client-compatible handle (has `.execute`). + */ +export async function consumeToken(db, { toolId, bucketKey, capacity, windowSeconds }) { + const now = Math.floor(Date.now() / 1000); + const refillRate = capacity / windowSeconds; + + const cur = await db.execute({ + sql: "SELECT tokens, refilled_at FROM rate_limit_buckets WHERE tool_id = ? AND bucket_key = ?", + args: [toolId, bucketKey], + }); + + let tokens; + let refilledAt = now; + if (cur.rows.length === 0) { + tokens = capacity - 1; + await db.execute({ + sql: `INSERT INTO rate_limit_buckets (tool_id, bucket_key, tokens, refilled_at) + VALUES (?, ?, ?, ?)`, + args: [toolId, bucketKey, tokens, refilledAt], + }); + return { allowed: true, remaining: tokens, retry_after: 0 }; + } + + const prevTokens = Number(cur.rows[0].tokens); + const prevRefilled = Number(cur.rows[0].refilled_at); + const elapsed = Math.max(0, now - prevRefilled); + tokens = Math.min(capacity, prevTokens + elapsed * refillRate); + + if (tokens < 1) { + const retryAfter = Math.ceil((1 - tokens) / refillRate); + // Persist the refill progress so clients see a monotonic count. + await db.execute({ + sql: "UPDATE rate_limit_buckets SET tokens = ?, refilled_at = ? WHERE tool_id = ? AND bucket_key = ?", + args: [tokens, now, toolId, bucketKey], + }); + return { allowed: false, remaining: Math.floor(tokens), retry_after: retryAfter }; + } + + tokens -= 1; + await db.execute({ + sql: "UPDATE rate_limit_buckets SET tokens = ?, refilled_at = ? WHERE tool_id = ? AND bucket_key = ?", + args: [tokens, now, toolId, bucketKey], + }); + return { allowed: true, remaining: Math.floor(tokens), retry_after: 0 }; +} + +/** + * Build a rate-limit wrapper bound to a DB handle + (optional) config path. + * Returns `limiter(toolId, handler)` — the wrapped handler is the shape + * MCP's `server.tool(..., handler)` expects. + */ +export function wrapRateLimited({ db, configPath = DEFAULT_CONFIG_PATH } = {}) { + const getBudgets = loadConfig(configPath); + + return function limiter(toolId, handler) { + return async (args, ctx) => { + const budgets = getBudgets(); + const budget = matchBudget(toolId, budgets); + if (!budget) return handler(args, ctx); // uncapped tool + + const bucketKey = resolveBucketKey(toolId, ctx); + const result = await consumeToken(db, { + toolId, + bucketKey, + capacity: budget.capacity, + windowSeconds: budget.window_seconds, + }); + if (!result.allowed) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + error: "rate_limited", + tool: toolId, + bucket: bucketKey, + retry_after_seconds: result.retry_after, + budget: `${budget.capacity}/${budget.window_seconds}s`, + }), + }], + isError: true, + }; + } + return handler(args, ctx); + }; + }; +}