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/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/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 {
`. 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
new file mode 100644
index 0000000..41f41ba
--- /dev/null
+++ b/bundles/maker-lab/curriculum/SCHEMA.md
@@ -0,0 +1,99 @@
+# 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. |
+| `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
+{
+ "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..90256d3
--- /dev/null
+++ b/bundles/maker-lab/curriculum/age-5-9/blockly-01-move-cat.json
@@ -0,0 +1,27 @@
+{
+ "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 press play to move the cat.",
+ "steps": [
+ { "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 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
new file mode 100644
index 0000000..c4fca1f
--- /dev/null
+++ b/bundles/maker-lab/curriculum/age-5-9/blockly-02-repeat.json
@@ -0,0 +1,31 @@
+{
+ "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 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. 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
new file mode 100644
index 0000000..2fc2406
--- /dev/null
+++ b/bundles/maker-lab/curriculum/age-5-9/blockly-03-on-click.json
@@ -0,0 +1,29 @@
+{
+ "id": "blockly-03-on-click",
+ "title": "The Cat Wakes Up",
+ "surface": "blockly",
+ "age_band": "5-9",
+ "reading_level": 3,
+ "goal": "Use an If block so something only happens when a question is true.",
+ "steps": [
+ { "prompt": "Find the If block." },
+ { "prompt": "Put a Do block inside it." },
+ { "prompt": "Press play and see what happens!" }
+ ],
+ "canned_hints": [
+ "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/manifest.json b/bundles/maker-lab/manifest.json
new file mode 100644
index 0000000..48bca51
--- /dev/null
+++ b/bundles/maker-lab/manifest.json
@@ -0,0 +1,65 @@
+{
+ "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",
+ "panelRoutes": "panel/routes.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..8c7f9a6
--- /dev/null
+++ b/bundles/maker-lab/package-lock.json
@@ -0,0 +1,1782 @@
+{
+ "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",
+ "qrcode": "^1.5.3",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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-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",
+ "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-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",
+ "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/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",
+ "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/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",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "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",
+ "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/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",
+ "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/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",
+ "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-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",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "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",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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/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",
+ "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..f1943c4
--- /dev/null
+++ b/bundles/maker-lab/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "crow-maker-lab",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "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
new file mode 100644
index 0000000..a66579f
--- /dev/null
+++ b/bundles/maker-lab/panel/maker-lab.js
@@ -0,0 +1,1328 @@
+/**
+ * Crow's Nest Panel — Maker Lab (Phase 2.1)
+ *
+ * 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)
+ *
+ * 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));
+
+async function loadDeviceBinding() {
+ return import(pathToFileURL(resolve(__dirname, "../server/device-binding.js")).href);
+}
+
+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 componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js");
+ const { escapeHtml } = await import(pathToFileURL(componentsPath).href);
+
+ 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'",
+ 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],
+ });
+ }
+
+ 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;
+
+ if (a === "set_mode") {
+ const mode = String(req.body.mode || "family");
+ if (["solo", "family", "classroom"].includes(mode)) {
+ 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 ins = await db.execute({
+ 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, age, avatar, consent_captured_at)
+ VALUES (?, ?, ?, datetime('now'))`,
+ args: [lid, age, avatar],
+ });
+ 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");
+ 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");
+ }
+
+ 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 === "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) {
+ 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 === "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);
+ 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 }),
+ });
+ }
+
+ // 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);
+ 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);
+ 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: 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.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
+ WHERE rp.type = 'learner_profile'
+ ORDER BY rp.created_at DESC`,
+ args: [],
+ });
+ 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 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 allActive) {
+ s.redemption_code = codesByToken.get(s.token) || null;
+ if (s.learner_id != null) activeByLearner.set(Number(s.learner_id), s);
+ }
+
+ 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 content = renderMainView({
+ mode, err, pendingDelete, showGuestPicker,
+ learners, allActive, activeByLearner, batches: batchesR.rows,
+ escapeHtml,
+ });
+
+ return layout({ title: `Maker Lab (${mode})`, content });
+ },
+};
+
+// ─── Render: QR handoff page ──────────────────────────────────────────────
+
+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'}
+
+
+ `;
+}
+
+// ─── Render: printable batch sheet ────────────────────────────────────────
+
+function renderBatchSheet({ batch, rows, escapeHtml, fullKioskUrl, publicBaseUrl }) {
+ const revoked = !!batch.revoked_at;
+ const cards = rows.map((r) => `
+
+ ${r.qrSvg || ''}
+
+
+ `).join("");
+
+ return `
+
+
+
+ ${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 →
+ `;
+
+ const createForm = `
+
+ + Add learner
+
+
+ `;
+
+ const renderLearnerCard = (l) => {
+ const active = activeByLearner.get(l.id);
+ const isPending = pendingDelete === l.id;
+ return `
+
+ ${mode === 'classroom' ? `` : ''}
+
+
+ ${active ? `
+
+ ${active.redemption_code ? `QR` : ''}
+ ` : `
+
+ `}
+ Settings
+ ${l.transcripts_enabled ? `Transcripts` : ''}
+ ${isPending ? `
+
+ Cancel
+ ` : `
+
+ `}
+
+
+ `;
+ };
+
+ const learnersHtml = mode === "classroom"
+ ? `${learners.map(renderLearnerCard).join("")}`
+ : `${learners.map(renderLearnerCard).join("")}`;
+
+ const bulkForm = mode === "classroom" ? `
+
+ ` : '';
+
+ 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.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))}
+
+ 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}
+
+ ⚙ Settings
+ 📚 Lessons
+
+ ${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 ``;
+}
+
+// ─── 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 `
+
+
+
+ ${savedBanner}${errBanner}
+
+
+
+ `;
+}
+
+// ─── 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))}…
+
+
+
+ ${turns.map((t) => `
+
+ ${t.role}
+ ${escapeHtml(t.content)}
+
+
+ `).join("")}
+
+
+ `;
+ }).join("");
+
+ return `
+
+
+
+ ← Back
+ Transcripts — ${escapeHtml(learner.name)}
+ ${enabled ? 'recording on' : 'recording off'}
+ Settings
+
+
+ ${sessionBlocks || '(nothing to show)
'}
+
+ `;
+}
+
+// ─── 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 ? `
+
+ Fingerprint Learner Bound Last seen
+
+ ${devices.map((d) => `
+
+ ${escapeHtml(d.fingerprint.slice(0, 12))}…
+ ${escapeHtml(d.learner_name || "(deleted)")}
+ ${escapeHtml(d.bound_at)}
+ ${escapeHtml(d.last_seen_at || "—")}
+
+
+
+
+ `).join("")}
+
+
+ ` : `(no bound devices)
`}
+
+ `;
+
+ return `
+
+
+
+ ${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.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.
+
+
+
+ `;
+
+ return `
+
+
+
+ ← Back
+ Lessons
+
+ ${banner}
+ ${bundledHtml}
+ ${customHtml}
+ ${importForm}
+
+ `;
+}
+
+function renderLessonImportResult({ errors, raw, escapeHtml }) {
+ return `
+
+
+ `;
+}
diff --git a/bundles/maker-lab/panel/routes.js b/bundles/maker-lab/panel/routes.js
new file mode 100644
index 0000000..ba7642f
--- /dev/null
+++ b/bundles/maker-lab/panel/routes.js
@@ -0,0 +1,647 @@
+/**
+ * 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, 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],
+ });
+ 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";
+}
+
+// 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 && startRetentionSweep) startRetentionSweep(db);
+ }
+ 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 ─────────────────────────────────────────
+ //
+ // 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) {
+ // 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(`
+
+ Kiosk unavailable
+
+ 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.
+
+ `);
+ }
+ }
+
+ 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(`
+
+ Maker Lab kiosk
+
+ Kiosk placeholder — the Blockly page is not built yet.
+ `);
+ }
+ 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) => {
+ 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;
+ const age = typeof s.learner_age === "number" ? s.learner_age : null;
+ const persona = s.is_guest ? ageBandFromGuestBand(s.guest_age_band) : personaForAge(age);
+
+ // /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,
+ is_guest: !!s.is_guest,
+ 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) => {
+ 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 });
+ }
+ });
+
+ // ─── /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) => {
+ 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..0f59820
--- /dev/null
+++ b/bundles/maker-lab/public/blockly/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+Maker Lab — Blockly
+
+
+
+
+
+
+ Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+Reconnecting…
+
+
+
+
+
+
+
diff --git a/bundles/maker-lab/public/blockly/kiosk.css b/bundles/maker-lab/public/blockly/kiosk.css
new file mode 100644
index 0000000..8686864
--- /dev/null
+++ b/bundles/maker-lab/public/blockly/kiosk.css
@@ -0,0 +1,67 @@
+: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;
+}
+
+.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
new file mode 100644
index 0000000..8609eaf
--- /dev/null
+++ b/bundles/maker-lab/public/blockly/tutor-bridge.js
@@ -0,0 +1,401 @@
+/**
+ * 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";
+let currentWorkspace = null;
+
+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 () => {
+ // 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",
+ 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;
+ speak("Great job!");
+ } catch {
+ await queuePush(payload);
+ offlineChip.hidden = false;
+ }
+});
+
+// ─── Connectivity ──────────────────────────────────────────────────────────
+
+window.addEventListener("online", async () => {
+ offlineChip.hidden = true;
+ await queueDrain(postProgress);
+});
+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() {
+ 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";
+ }
+ 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)}`);
+ 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;
+ }
+}
+
+// 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;
+ }
+ return Blockly.inject("blocklyArea", {
+ 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(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).
+ 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(() => {}); }
+}
+
+init();
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/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/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/filters.js b/bundles/maker-lab/server/filters.js
new file mode 100644
index 0000000..f85cbbe
--- /dev/null
+++ b/bundles/maker-lab/server/filters.js
@@ -0,0 +1,161 @@
+/**
+ * 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;
+ try {
+ 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) {
+ 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..673b258
--- /dev/null
+++ b/bundles/maker-lab/server/index.js
@@ -0,0 +1,28 @@
+#!/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";
+import { startRetentionSweep } from "./retention-sweep.js";
+
+const db = createDbClient();
+
+await initMakerLabTables(db);
+startRetentionSweep(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..9fe6e2a
--- /dev/null
+++ b/bundles/maker-lab/server/init-tables.js
@@ -0,0 +1,149 @@
+/**
+ * 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. 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,
+ 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'))
+ );
+ `);
+
+ // 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'))");
+ } catch (err) {
+ // Non-fatal — table may not yet have rows.
+ }
+}
diff --git a/bundles/maker-lab/server/lesson-validator.js b/bundles/maker-lab/server/lesson-validator.js
new file mode 100644
index 0000000..2d65a75
--- /dev/null
+++ b/bundles/maker-lab/server/lesson-validator.js
@@ -0,0 +1,147 @@
+/**
+ * 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");
+ }
+ }
+
+ // 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 };
+}
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);
+}
diff --git a/bundles/maker-lab/server/server.js b/bundles/maker-lab/server/server.js
new file mode 100644
index 0000000..b5a1228
--- /dev/null
+++ b/bundles/maker-lab/server/server.js
@@ -0,0 +1,679 @@
+/**
+ * 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 {
+ personaForAge,
+ ageBandFromGuestBand,
+ resolvePersonaForSession,
+ getLearnerAge,
+ filterHint,
+} from "./filters.js";
+import { handleHintRequest } from "./hint-pipeline.js";
+import {
+ SESSION_DEFAULT_MIN,
+ SESSION_MAX_MIN,
+ mintSessionForLearner,
+ mintGuestSession,
+ mintBatchSessions,
+} from "./sessions.js";
+
+const ENDING_FLUSH_SEC = 5;
+
+// ─── 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 res = await db.execute({
+ 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, 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) {
+ 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.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
+ WHERE rp.type = 'learner_profile'
+ ORDER BY rp.created_at DESC`,
+ args: [],
+ });
+ 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 });
+ }
+ );
+
+ 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.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];
+ return mcpOk({
+ 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,
+ 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 id FROM research_projects WHERE id=? AND type='learner_profile'`,
+ args: [learner_id],
+ });
+ if (!r.rows.length) return mcpError(`Learner ${learner_id} not found`);
+ 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 (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) {
+ 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 }) => {
+ 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);
+ }
+ }
+ );
+
+ 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, 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,
+ });
+ }
+ );
+
+ 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 r = await mintGuestSession(db, { ageBand: age_band });
+ return mcpOk({
+ redemption_code: r.redemptionCode,
+ short_url: r.shortUrl,
+ persona: ageBandFromGuestBand(age_band),
+ session_expires_at: r.sessionExpiresAt,
+ 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 { validateLesson } = await import("./lesson-validator.js");
+ const { valid, errors } = validateLesson(lesson);
+ return mcpOk({ valid, errors });
+ }
+ );
+
+ return server;
+}
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 };
+}
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..a34e128 100644
--- a/registry/add-ons.json
+++ b/registry/add-ons.json
@@ -1,6 +1,32 @@
{
"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",
+ "panelRoutes": "panel/routes.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/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/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/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)) {
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/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/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/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);
+ };
+ };
+}
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",
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 |