From 55861b0778a8cfd70f932c11dc059f528072cc7b Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Thu, 30 Jul 2026 10:42:47 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Add=20A2APP=20=E2=80=94=20make=20agent=20wr?= =?UTF-8?q?ites=20to=20Living=20UIs=20verifiable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent asked to "add a todo for tomorrow" wrote due_date: "tomorrow". PocketBase returned 200, stored an empty string, and the agent told the user it was scheduled. It had to guess what the app contained, the write silently failed, and nothing stopped it claiming otherwise. All three are now handled in the app, so any agent benefits — verified by driving an app with curl alone, after deleting .superuser. In the app (new pb_hooks, adapter 1.6.0): - describe: entities, protocol types, conventions, from the live schema - identity: PocketBase answers 200 for unknown paths, so an "a2app" marker is the only reliable probe - write guard as router middleware, not a record hook — PocketBase coerces first, after which "tomorrow" and "" are indistinguishable - read-back backstop; errors carry a code and list every violation - origin guard, agent token, ops auth, rate limits, idempotency In the CLI: - reads describe, not PocketBase's superuser-only admin endpoint, so it holds no privilege an outside agent lacks - resolves dates and labels client-side, where a clock and Intl exist - adapter-sync ships hooks without re-vendoring the kit - a valueless --flag now errors instead of becoming `true` - gate rejects e.app inside runInTransaction, which deadlocks the process while /api/health still returns 200 In CraftBot: - the system reports what changed, from the stored record; a false claim is withheld and handed back rather than corrected in front of the user - the data model is inlined into the prompt — three attempts became one - skills load per run, so operating an app no longer carries the build recipe that rebuilt a live app after one row insert - integration bridge: capability gate, destination allowlist, no redirects adapter-sync runs at create, install, import and launch — launch is the only path reaching an app a user already had. Adds spec/OVERVIEW.md and scripts/a2app-selfcheck.sh (21 checks). --- app/agent_base.py | 285 +++++- app/data/action/living_ui_actions.py | 19 +- app/living_ui/agent_view.py | 184 ++++ app/living_ui/integration_bridge.py | 142 ++- app/living_ui/manager.py | 38 +- app/living_ui/v2_runner.py | 62 ++ living-ui-v2/.gitignore | 1 + .../blueprint/pb/pb_hooks/_a2app.pb.js | 78 ++ .../blueprint/pb/pb_hooks/_a2app_lib.js | 499 ++++++++++ .../blueprint/pb/pb_hooks/_a2app_rules.js | 251 +++++ .../blueprint/pb/pb_hooks/_system.pb.js | 256 +++++- living-ui-v2/scripts/a2app-selfcheck.sh | 132 +++ living-ui-v2/spec/OVERVIEW.md | 863 ++++++++++++++++++ living-ui-v2/tools/src/cli.ts | 1 + .../tools/src/commands/adapter-sync.ts | 48 + living-ui-v2/tools/src/commands/data.ts | 116 ++- living-ui-v2/tools/src/commands/kit-sync.ts | 15 +- living-ui-v2/tools/src/commands/validate.ts | 54 ++ living-ui-v2/tools/src/lib/hashes.ts | 10 +- living-ui-v2/tools/src/lib/kit.ts | 45 +- living-ui-v2/tools/src/lib/project.ts | 23 +- living-ui-v2/tools/src/lib/schema.ts | 260 ++++++ 22 files changed, 3334 insertions(+), 48 deletions(-) create mode 100644 app/living_ui/agent_view.py create mode 100644 living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js create mode 100644 living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js create mode 100644 living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js create mode 100755 living-ui-v2/scripts/a2app-selfcheck.sh create mode 100644 living-ui-v2/spec/OVERVIEW.md create mode 100644 living-ui-v2/tools/src/commands/adapter-sync.ts create mode 100644 living-ui-v2/tools/src/lib/schema.ts diff --git a/app/agent_base.py b/app/agent_base.py index e28775c4..82e16500 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -24,6 +24,7 @@ import asyncio import os +import re import shutil import traceback import time @@ -301,6 +302,12 @@ def __init__( agent_file_system_path=AGENT_FILE_SYSTEM_PATH, ) + # A2APP claim gate (spec A2APP-PLAN Phase 1 B10): what this run has + # actually written to a Living UI, and how many messages have been + # withheld for misreporting it. Both reset when the run ends. + self._lui_run_writes: Dict[str, list] = {} + self._lui_blocked_claims: Dict[str, int] = {} + # action layer self.action_library = ActionLibrary(self.llm, db_interface=self.db_interface) @@ -1046,6 +1053,15 @@ async def _execute_actions( f"[ACTION] Ready to run {len(actions_with_input)} action(s): {action_names}" ) + # A2APP: a message that misreports what happened is stopped BEFORE the + # user sees it, and the discrepancy is handed back to the agent to fix. + # Correcting it in front of the user (a System bubble contradicting the + # assistant) is worse than not saying it — the agent should just be + # right. See spec/A2APP-PLAN.md Phase 1 B10. + actions_with_input = self._gate_false_claims(session_id, actions_with_input) + if not actions_with_input: + return {} # nothing ran; run continues so the agent can try again + results = await self.action_manager.execute_actions_parallel( actions=actions_with_input, context=context, @@ -1055,8 +1071,226 @@ async def _execute_actions( is_running_task=True, ) + # A2APP: when the agent writes to a Living UI, the SYSTEM reports what + # actually landed. See spec/A2APP-PLAN.md Phase 1 B10/B11. + self._report_living_ui_writes(session_id, actions_with_input, results) + return self._merge_action_outputs(results) + # Past-tense MUTATION verbs only. Recall is traded away for precision on + # purpose: a missed lie is no worse than today, but a wrongly withheld + # message is invisible to the user and strictly worse. + # + # "done" and "all set" were here and had to go — in a kanban app "Done" is + # a list name, so "Which list: To Do, In Progress or Done?" was blocked in + # testing. Swallowing a clarifying question is the worst outcome this gate + # can produce, so ambiguous words are out and questions are exempt. + _SUCCESS_CLAIM = re.compile( + r"\b(added|created|updated|deleted|removed|saved|scheduled|moved|renamed)\b", + re.IGNORECASE, + ) + + def _gate_false_claims(self, session_id: str, actions_with_input: list) -> list: + """Drop a `send_message` whose claim the record does not support. + + Only ONE rule is enforced, and it needs no language understanding: + *the agent says it changed something, and nothing was successfully + written this run.* That is exact, and it covers the largest class of + false success. Fuzzier checks (does the prose's date match the stored + date?) are only LOGGED, until the telemetry says they would be right. + + A blocked message is not shown to the user. The reason goes into the + event stream, so the agent reads it on its next turn and corrects + itself. After one correction attempt the message is allowed through + regardless — looping in silence is worse than one imprecise sentence. + """ + writes = self._lui_run_writes.get(session_id) or [] + if writes: + return actions_with_input # something was written; nothing to dispute + + kept = [] + for action, params in actions_with_input: + if getattr(action, "name", None) != "send_message": + kept.append((action, params)) + continue + + message = str((params or {}).get("message") or "") + # A question is never an assertion that something changed, and + # withholding one strands the user waiting on an answer. + if message.rstrip().endswith("?"): + kept.append((action, params)) + continue + if not self._SUCCESS_CLAIM.search(message): + kept.append((action, params)) + continue + if not self._session_touches_living_ui(session_id): + kept.append((action, params)) + continue + + blocked = self._lui_blocked_claims.get(session_id, 0) + if blocked >= 1: + logger.warning( + f"[A2APP] claim still unsupported after a correction; " + f"allowing it through for session={session_id}" + ) + kept.append((action, params)) + continue + + self._lui_blocked_claims[session_id] = blocked + 1 + logger.warning(f"[A2APP] blocked unsupported claim: {message[:120]}") + if self.event_stream_manager: + self.event_stream_manager.log( + kind="action_error", + message=( + "Your message was NOT sent. It says you changed something, but no " + "write to this Living UI succeeded in this run. Either perform the " + "write, or tell the user plainly what went wrong. Do not claim an " + "action you did not complete." + ), + event_type=EventType.ACTION_END, + display_message="message withheld — claim not supported by any write", + action_name="send_message", + action_output={"status": "blocked", "reason": "unsupported_claim"}, + task_id=session_id, + ) + return kept + + def _session_touches_living_ui(self, session_id: str) -> bool: + try: + session = self.session_manager.get(session_id) + except Exception: + return False + return bool(getattr(session, "living_ui_project_id", None)) if session else False + + # Recognises a WRITE through the lui CLI. Reads (list/get) are ignored: + # they change nothing and need no receipt. + _LUI_WRITE = re.compile( + r"cli\.ts\s+(?:data\s+\S+\s+(?P\S+)\s+(?Pcreate|update|delete)" + r"|run\s+\S+\s+(?P[\w.\-]+))" + ) + + def _report_living_ui_writes( + self, session_id: str, actions_with_input: list, results: list + ) -> None: + """Report what a turn changed, IN CRAFTBOT'S VOICE, and refresh the app. + + Why the system writes it: in the incident that motivated A2APP the + agent wrote a card with an empty due date, read `"due_date":""` in its + own tool output, and told the user "scheduled for tomorrow". Guarding + the write stops the bad data; it does not stop the false sentence. + + Why it is not a separate "System" speaker: it was, and it read badly — + the user saw a grey robot line restating what the assistant then said + again, less precisely ("due tomorrow" against the receipt's "due Fri 31 + Jul") and padded with filler. Delivering the fact AS CraftBot removes + the duplication and the extra narration turn, and keeps the guarantee: + the words come from the stored record, not from the model. + + One line per turn, not per write, so a turn that changes three things + does not produce three bubbles. (A bulk run spread over many turns + still yields many lines — see A2APP-PLAN for the open case.) + + Also the only place `dispatch_living_ui_data_changed` fires on the CLI + path — previously it fired solely from the deprecated `living_ui_http` + action, so agent writes never refreshed the iframe. + """ + try: + session = self.session_manager.get(session_id) + except Exception: + session = None + project_id = getattr(session, "living_ui_project_id", None) if session else None + if not project_id: + return + + summaries = [] + for (action, params), result in zip(actions_with_input, results): + try: + if getattr(action, "name", None) != "run_shell": + continue + command = str((params or {}).get("command") or "") + match = self._LUI_WRITE.search(command) + if match is None: + continue + summary = self._describe_write(session_id, project_id, match, result) + if summary: + summaries.append(summary) + except Exception as e: # a receipt must never break the turn + logger.debug(f"[A2APP] receipt skipped: {e}") + + if not summaries: + return + + if self.event_stream_manager: + text = summaries[0] if len(summaries) == 1 else "\n".join(f"• {s}" for s in summaries) + self.event_stream_manager.log( + kind="living_ui_write", + message=text, + event_type=EventType.AGENT_MESSAGE, + display_message=text, + task_id=session_id, + ) + + try: + from app.living_ui import dispatch_living_ui_data_changed + + dispatch_living_ui_data_changed(project_id) + except Exception as e: + logger.debug(f"[A2APP] data-changed dispatch skipped: {e}") + + def _describe_write( + self, session_id: str, project_id: str, match, result: dict + ) -> Optional[str]: + """One CLI write result -> one plain sentence, or None if there is + nothing the user needs to read.""" + import json as _json + + collection = match.group("collection") + verb = match.group("verb") + target = match.group("op") or f"{collection}.{verb}" + stdout = str((result or {}).get("stdout") or "") + stderr = str((result or {}).get("stderr") or "") + failed = (result or {}).get("status") == "error" or (result or {}).get( + "return_code" + ) not in (0, None) + + # A failure the agent goes on to recover from is NOT an event in the + # user's world — it is an internal retry, and putting it in the chat + # reads like the assistant arguing with itself. The agent still sees it + # (action_end carries the full stderr) and so does anyone who opens the + # actions detail; the conversation stays about what the user asked for. + if failed: + logger.info(f"[A2APP] {target} rejected: {(stderr or stdout).strip()[:200]}") + return None + + record = None + try: + parsed = _json.loads(stdout) + if isinstance(parsed, dict) and "id" in parsed: + record = parsed + except Exception: + record = None + + summary = f"{target} ok" + if record is not None and collection: + try: + from app.living_ui import get_living_ui_manager + from app.living_ui.agent_view import humanise_write + + mgr = get_living_ui_manager() + proj = mgr.get_project(project_id) if mgr else None + base = (proj.backend_url or proj.url) if proj else None + if base: + summary = humanise_write( + base.rstrip("/"), collection, verb or "create", record + ) + except Exception as e: + logger.debug(f"[A2APP] could not humanise receipt: {e}") + + self._lui_run_writes.setdefault(session_id, []).append( + {"collection": collection, "verb": verb, "record": record, "summary": summary} + ) + return summary + def _merge_action_outputs(self, outputs: list) -> dict: """ Merge outputs from parallel actions into single response. @@ -1103,6 +1337,10 @@ async def _finalize_turn( run_ends = bool(action_output.get("run_ends", False)) if run_ends: + # The claim gate is scoped to a run: what was written for THIS + # request says nothing about the next one. + self._lui_run_writes.pop(session.id, None) + self._lui_blocked_claims.pop(session.id, None) await self._on_run_end(session, trigger.payload or {}) return @@ -1750,19 +1988,48 @@ def _build_living_ui_note(living_ui_project_id: str) -> str: if mgr: proj = mgr.get_project(living_ui_project_id) if proj: + # The DATA MODEL goes in the prompt, not behind a pointer. + # Twice now the agent has ignored "Read LIVING_UI.md", never + # run `lui ops`, and guessed collection names instead + # (`items`, then `tasks`) — and once invented an enum value + # (`priority: "normal"`) it could not have known was wrong. + # Advisory text does not work on a weak model; context does. + schema = None + try: + from app.living_ui.agent_view import schema_block + + base = proj.backend_url or proj.url + if base: + schema = schema_block(base.rstrip("/")) + except Exception: + schema = None + + model = ( + f"Data model (field(type), * = required):\n{schema}\n" + if schema + else f"Data model: run node {_lui_cli} data {proj.path} schema\n" + ) return ( f"[INTERACTING WITH LIVING UI: {proj.name} ({living_ui_project_id})]\n" f"Project path: {proj.path}\n" - f"Read {proj.path}/LIVING_UI.md for app context.\n" - f"If debugging issues, FIRST read these logs:\n" - f" - {proj.path}/logs/pocketbase.log (server, migrations, crashes)\n" - f" - {proj.path}/logs/frontend_console.log (frontend errors, network failures)\n" - f"To OPERATE the app (read/write data, run its verbs), use the lui CLI via run_shell\n" - f"(preferred over living_ui_http). Use these EXACT absolute commands (the shell's\n" - f"cwd is NOT the repo root — relative paths will fail):\n" - f" node {_lui_cli} ops {proj.path}\n" + f"{model}" + f"Values: dates as ISO or 'tomorrow'/'next monday' (the CLI resolves them);\n" + f"references by name, e.g. --list \"To Do\". Only set fields the user asked for.\n" + f"AFTER A SUCCESSFUL WRITE the user is ALREADY shown exactly what changed, in\n" + f"your voice, generated from the stored record. Do NOT send a message repeating\n" + f"it — end the turn. Send a message only to add something that report does not\n" + f"cover: a failure, a question, an answer to a question, or a summary of many\n" + f"changes.\n" + f"To OPERATE the app, use the lui CLI via run_shell with ABSOLUTE paths\n" + f"(the shell's cwd is NOT the repo root):\n" + f' node {_lui_cli} data {proj.path} create --field "value"\n' + f' ALWAYS quote values — an unquoted # starts a shell comment and\n' + f' silently drops the rest of the command.\n' + f" node {_lui_cli} data {proj.path} list --limit 20\n" f" node {_lui_cli} run {proj.path} --param value\n" - f" node {_lui_cli} data {proj.path} list --limit 20" + f"If debugging, read {proj.path}/logs/pocketbase.log and logs/frontend_console.log.\n" + f"Using the app needs no skill. To CHANGE its code, or import/diagnose one,\n" + f"load the right Living UI skill first (use_skill); list_skills shows all skills." ) except Exception: pass diff --git a/app/data/action/living_ui_actions.py b/app/data/action/living_ui_actions.py index 4204583b..41710a95 100644 --- a/app/data/action/living_ui_actions.py +++ b/app/data/action/living_ui_actions.py @@ -191,10 +191,13 @@ async def living_ui_scaffold(input_data: dict) -> dict: @action( name="living_ui_notify_ready", description=( - "Launch, verify, and serve a Living UI project. " - "Call this after building the Living UI code. " - "This action installs dependencies, runs tests, starts the backend and frontend, " - "and notifies the browser. Returns test errors if anything fails." + "Launch or RELAUNCH a Living UI project: installs dependencies, runs the " + "validation gate, restarts backend and frontend, notifies the browser. " + "Call this ONLY after CREATING or CHANGING the app's CODE (migrations, " + "hooks, frontend). An app that is already running does NOT need it — " + "adding, editing or deleting DATA never requires a relaunch, and calling " + "it then rebuilds and restarts a live app for no reason. " + "Returns test errors if anything fails." ), default=False, mode="CLI", @@ -329,9 +332,13 @@ async def living_ui_notify_ready(input_data: dict) -> dict: "UI project: a real browser (headless) drives the app " "feature-by-feature against reference/requirements.md. A clean " "verdict announces the app to the user — the ONLY way a Living UI " - "build completes. Observed defects return the failure report: fix, " + "BUILD completes. Observed defects return the failure report: fix, " "relaunch with living_ui_notify_ready, then call this again. " - "Requires the app to be running (living_ui_notify_ready first)." + "Requires the app to be running (living_ui_notify_ready first). " + "ONLY after building or modifying the app's CODE. NEVER after a data " + "change: it drives a real browser and CLICKS through the UI, including " + "buttons that create records, so running it against an app holding the " + "user's data can alter that data." ), default=False, mode="CLI", diff --git a/app/living_ui/agent_view.py b/app/living_ui/agent_view.py new file mode 100644 index 00000000..c204df28 --- /dev/null +++ b/app/living_ui/agent_view.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +""" +What the agent and the user each SEE of a Living UI. + +Two jobs, both about presentation rather than mechanism: + +1. `schema_block()` — the app's data model, inlined into the agent's prompt. + Advisory pointers do not work on weak models: across two recorded incidents + the agent ignored "Read LIVING_UI.md", never ran `lui ops`, and guessed + collection names instead (`items`, `tasks`). It cannot ignore what is + already in its context. + +2. `humanise_write()` — one plain sentence describing what a write actually + did, built from the stored record. The user should never read + `cards.create [kapp872i5etufxb] due_date='2026-07-31 00:00:00.000Z'`. + +Both read the app's own A2APP `describe` surface, so neither can drift from +what the app actually is. +""" + +from __future__ import annotations + +import json +import time +import urllib.request +from datetime import datetime +from typing import Any, Dict, Optional + +try: + from app.logger import logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + +# describe is cheap but not free, and it is fetched on every user message. +# A few minutes of staleness is harmless: the app validates writes itself, so +# a stale block can only cost a retry, never a bad write. +_CACHE: Dict[str, tuple] = {} +_TTL_SECONDS = 300 +_TIMEOUT_SECONDS = 2.0 + +_SKIP_FIELDS = {"id", "collectionId", "collectionName", "created", "updated"} + + +def _describe(base_url: str) -> Optional[Dict[str, Any]]: + """Fetch (and cache) the app's data model. None when the app is down.""" + cached = _CACHE.get(base_url) + if cached is not None and time.time() - cached[0] < _TTL_SECONDS: + return cached[1] + try: + request = urllib.request.Request( + f"{base_url}/api/_a2app/describe", headers={"User-Agent": "CraftBot"} + ) + with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response: + data = json.loads(response.read().decode("utf-8")) + _CACHE[base_url] = (time.time(), data) + return data + except Exception as e: + logger.debug(f"[AGENT_VIEW] describe unavailable at {base_url}: {e}") + _CACHE[base_url] = (time.time(), None) + return None + + +def _type_label(spec: Dict[str, Any]) -> str: + """Render a field's type the way the agent needs to see it — including the + enum's actual values, whose absence caused a rejected write.""" + kind = str(spec.get("type", "string")) + if kind == "enum" and spec.get("values"): + return "one of " + "|".join(str(v) for v in spec["values"]) + if kind in ("ref", "list") and spec.get("entity"): + arrow = "->" if kind == "ref" else "->[]" + return f"{arrow}{spec['entity']}" + if spec.get("format"): + return str(spec["format"]) + return kind + + +def schema_block(base_url: str, max_chars: int = 2000) -> Optional[str]: + """The data model, compact enough to sit in every prompt. + + Read-only and server-managed fields are omitted: the agent cannot write + them, so naming them only invites it to try. + """ + described = _describe(base_url) + if not described: + return None + entities = described.get("entities") or {} + if not entities: + return None + + lines = [] + for name, entity in entities.items(): + fields = [] + for field_name, spec in (entity.get("fields") or {}).items(): + if spec.get("readOnly"): + continue + star = "*" if spec.get("required") else "" + fields.append(f"{field_name}({_type_label(spec)}){star}") + if fields: + lines.append(f" {name}: {' '.join(fields)}") + + block = "\n".join(lines) + if len(block) > max_chars: # very large apps: names only, still better than nothing + block = "\n".join(f" {n}: {len((e.get('fields') or {}))} fields" for n, e in entities.items()) + return block + + +def _resolve_ref(base_url: str, entity: str, record_id: str) -> Optional[str]: + """A referenced record's human label, so the user reads 'To Do' not an id.""" + described = _describe(base_url) + if not described: + return None + target = (described.get("entities") or {}).get(entity) or {} + label_field = target.get("label") + if not label_field: + return None + try: + url = f"{base_url}/api/collections/{entity}/records/{record_id}" + with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as response: + record = json.loads(response.read().decode("utf-8")) + value = record.get(label_field) + return str(value) if value else None + except Exception: + return None + + +def _humanise_date(value: str) -> str: + """'2026-07-31 00:00:00.000Z' -> 'Fri 31 Jul'. Times are kept when present.""" + text = str(value).strip() + try: + stamp = datetime.fromisoformat(text.replace("Z", "+00:00").replace(" ", "T", 1)) + except Exception: + return text[:10] or text + if stamp.hour == 0 and stamp.minute == 0: + return stamp.strftime("%a %-d %b") + return stamp.strftime("%a %-d %b %H:%M") + + +_VERBS = {"create": "Added", "update": "Updated", "delete": "Removed"} + + +def humanise_write(base_url: str, collection: str, op: str, record: Dict[str, Any]) -> str: + """One sentence a person can read, built from what was actually stored. + + Example: Added "Eat chicken" to To Do — due Fri 31 Jul, priority medium + """ + described = _describe(base_url) + entities = (described or {}).get("entities") or {} + entity = entities.get(collection) or {} + specs = entity.get("fields") or {} + label_field = entity.get("label") + + name = record.get(label_field) if label_field else None + verb = _VERBS.get(op, "Changed") + subject = f'"{name}"' if name else f"a {collection.rstrip('s')}" + + into = "" + details = [] + for key, value in record.items(): + if key in _SKIP_FIELDS or key == label_field: + continue + if value in ("", None, [], {}, False, 0): + continue + spec = specs.get(key) or {} + kind = str(spec.get("type", "")) + + if kind == "ref" and spec.get("entity"): + resolved = _resolve_ref(base_url, str(spec["entity"]), str(value)) + if resolved and not into: + into = f" to {resolved}" # the containing thing reads best inline + continue + details.append(f"{key.replace('_', ' ')} {resolved or value}") + elif kind == "datetime": + details.append(f"{key.replace('_', ' ').replace(' date', '')} {_humanise_date(value)}") + elif kind in ("json", "binary", "list"): + continue # nothing a person wants to read + else: + details.append(f"{key.replace('_', ' ')} {value}") + + sentence = f"{verb} {subject}{into}" + if details: + sentence += " — " + ", ".join(details[:4]) + return sentence diff --git a/app/living_ui/integration_bridge.py b/app/living_ui/integration_bridge.py index d52caecb..947dc079 100644 --- a/app/living_ui/integration_bridge.py +++ b/app/living_ui/integration_bridge.py @@ -12,7 +12,8 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Optional +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Optional from aiohttp import web import httpx @@ -38,7 +39,9 @@ class IntegrationBridge: def __init__(self, manager: "LivingUIManager"): self._manager = manager - self._http_client = httpx.AsyncClient(timeout=30, follow_redirects=True) + # follow_redirects is OFF for the proxy: an allowed host could 302 to an + # attacker-controlled host and the injected credentials would follow it. + self._http_client = httpx.AsyncClient(timeout=30, follow_redirects=False) def register_routes(self, app: web.Application) -> None: """Register integration bridge routes on the aiohttp app.""" @@ -110,6 +113,39 @@ async def _handle_proxy(self, request: web.Request) -> web.Response: {"error": "Missing required fields: integration, url"}, status=400 ) + # Gate 1 — capability. An app may only use integrations its manifest + # declares. Without this, any app that can reach the bridge can use + # every credential the user has connected: a kanban board could send + # mail as them. The declaration is the hook the Phase 5 consent flow + # attaches to; until then it is at least an explicit, reviewable list. + granted, why = self._project_grants(project_id, integration) + if not granted: + logger.warning( + f"[INTEGRATION_BRIDGE] BLOCKED (capability) project={project_id} " + f"integration={integration!r}: {why}" + ) + return web.json_response( + {"error": f"This app is not permitted to use '{integration}': {why}"}, + status=403, + ) + + # Gate 2 — destination. Without it this endpoint is a credential + # exfiltration primitive: `url` is caller-controlled and the user's real + # OAuth token is injected into whatever host is named, so one line in a + # third-party app's pb_hooks could ship a Gmail token anywhere. Fails + # CLOSED — an integration with no entry cannot be proxied at all. + allowed, resolved = self._resolve_destination(integration, url) + if not allowed: + logger.warning( + f"[INTEGRATION_BRIDGE] BLOCKED proxy from project={project_id} " + f"integration={integration!r} url={url!r}: {resolved}" + ) + return web.json_response( + {"error": f"Destination not permitted for '{integration}': {resolved}"}, + status=403, + ) + url = resolved + # Get auth headers from platform client auth_headers = self._get_auth_headers(integration) if auth_headers is None: @@ -256,6 +292,108 @@ def _validate_token(self, request: web.Request) -> Optional[str]: token = auth[7:] return self._manager.validate_bridge_token(token) + # Where each integration's credentials may be sent: (base, allowed hosts). + # + # `base` also REPAIRS the proxy. Callers pass a path — the shipped apps do + # `callIntegration('gmail','POST','/gmail/v1/users/me/messages/send',…)` — + # and nothing ever resolved it, so httpx got a relative URL and every call + # failed. crm-system even carries a "Gmail integration unavailable" fallback + # because of it. Resolving against `base` fixes that, and a path can never + # escape the base, so it is also the safe form. + # + # Host matching is exact-or-dot-suffix, so "api.github.com.evil.com" does + # not pass as "api.github.com". Omission denies — the safe direction. + PROXY_DESTINATIONS: Dict[str, tuple] = { + "github": ("https://api.github.com", ("api.github.com",)), + "gmail": ("https://www.googleapis.com", ("googleapis.com",)), + "google_calendar": ("https://www.googleapis.com", ("googleapis.com",)), + "google_docs": ("https://docs.googleapis.com", ("googleapis.com",)), + "google_drive": ("https://www.googleapis.com", ("googleapis.com",)), + "google_youtube": ("https://www.googleapis.com", ("googleapis.com",)), + "google_workspace": ("https://www.googleapis.com", ("googleapis.com",)), + "outlook": ("https://graph.microsoft.com", ("graph.microsoft.com",)), + "slack": ("https://slack.com", ("slack.com",)), + "discord": ("https://discord.com", ("discord.com", "discordapp.com")), + "notion": ("https://api.notion.com", ("api.notion.com",)), + "hubspot": ("https://api.hubapi.com", ("api.hubapi.com",)), + "jira": ("https://api.atlassian.com", ("atlassian.net", "api.atlassian.com")), + "linkedin": ("https://api.linkedin.com", ("api.linkedin.com",)), + "stripe": ("https://api.stripe.com", ("api.stripe.com",)), + "line": ("https://api.line.me", ("api.line.me",)), + "lark": ("https://open.feishu.cn", ("open.feishu.cn", "open.larksuite.com")), + "lark_calendar": ("https://open.feishu.cn", ("open.feishu.cn", "open.larksuite.com")), + "lark_drive": ("https://open.feishu.cn", ("open.feishu.cn", "open.larksuite.com")), + "telegram_bot": ("https://api.telegram.org", ("api.telegram.org",)), + "telegram_user": ("https://api.telegram.org", ("api.telegram.org",)), + "twitter": ("https://api.twitter.com", ("api.twitter.com", "api.x.com")), + "whatsapp_business": ("https://graph.facebook.com", ("graph.facebook.com",)), + } + + def _project_grants(self, project_id: str, integration: str) -> tuple: + """(ok, reason) — does this project declare `integration` in its + manifest's `capabilities.integrations`? Fails closed.""" + import json as _json + + try: + project = self._manager.get_project(project_id) + except Exception as e: + return False, f"unknown project ({e})" + if project is None: + return False, "unknown project" + + try: + manifest_path = Path(project.path) / "manifest.json" + manifest = _json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as e: + return False, f"manifest unreadable ({e})" + + capabilities = manifest.get("capabilities") or {} + declared = capabilities.get("integrations") + if not isinstance(declared, list): + return False, ( + "manifest declares no capabilities.integrations — add " + f'"capabilities": {{"integrations": ["{integration}"]}} to grant it' + ) + if integration not in declared: + return False, f"not in capabilities.integrations {declared}" + return True, "" + + def _resolve_destination(self, integration: str, url: str) -> tuple: + """(ok, resolved_url_or_reason) for this integration's credentials.""" + from urllib.parse import urlparse + + entry = self.PROXY_DESTINATIONS.get(integration) + if not entry: + return False, "integration has no permitted destinations" + base, allowed = entry + + raw = (url or "").strip() + if not raw: + return False, "empty url" + + # A path resolves against the base and cannot escape it. Note "//host" + # is protocol-relative, NOT a path — urljoin would happily send it to + # another host, so it must be rejected here. + if raw.startswith("/") and not raw.startswith("//"): + return True, base.rstrip("/") + raw + + try: + parsed = urlparse(raw) + except Exception: + return False, "unparseable url" + + if parsed.scheme != "https": + return False, f"scheme {parsed.scheme!r} is not https" + + host = (parsed.hostname or "").lower() + if not host: + return False, "no host in url" + + for candidate in allowed: + if host == candidate or host.endswith("." + candidate): + return True, raw + return False, f"host {host!r} is not one of {', '.join(allowed)}" + def _get_auth_headers(self, platform_id: str) -> Optional[dict]: """ Get authentication headers from a platform client. diff --git a/app/living_ui/manager.py b/app/living_ui/manager.py index 1ebea085..44c72d73 100644 --- a/app/living_ui/manager.py +++ b/app/living_ui/manager.py @@ -177,8 +177,26 @@ def bind_session_manager( def ensure_project_session(self, project: "LivingUIProject"): """Ensure the project's dedicated session exists and return it. - Creates the session on first use with the Living UI toolchain - preloaded (living-ui-creator skill + build action sets). + NO Living UI skill is preloaded. Which skill a run needs depends on + what is being ASKED, not on how the project arrived, so the agent picks + one per run. Ordinary data work needs none at all — the interaction + note carries the data model and the exact commands, and the app itself + publishes how to drive it at GET /api/_a2app/describe, which is also + the only copy an agent outside CraftBot can read. + + System-dispatched runs whose purpose IS known (crash repair, a + development run) still declare their skill on the trigger via + `workflow_skills`, loaded at run start and unloaded at run end — + nothing has to infer what those runs are for. + + This used to preload living-ui-creator unconditionally, because + originally every Living UI was one the agent had just written. Install + and import were added later and reused this helper, so an app that + arrived fully built still got a BUILD session — and the creator skill's + "Finish: launch, then verify" recipe permanently in its prompt. The + observed cost: asking a freshly installed habit tracker to record one + habit relaunched the app twice, re-ran the validation gate, and drove a + headless browser that clicked "Add habit" ten times against real data. """ if not self._session_manager: return None @@ -198,7 +216,7 @@ def ensure_project_session(self, project: "LivingUIProject"): title=project.name, session_id=project.session_id or f"lui_{project.id}", action_sets=["file_operations", "code_execution", "living_ui"], - selected_skills=["living-ui-creator"], + selected_skills=[], living_ui_project_id=project.id, ) project.session_id = session.id @@ -457,7 +475,12 @@ async def _escalate_crash(self, project_id: str, crash_targets: List[str]) -> No description=task_instruction, priority=30, # Higher priority than normal creation runs session_id=session.id, - payload={"project_id": project_id}, + payload={ + "project_id": project_id, + # This run writes code, so it needs the build skill — + # loaded now, unloaded when the run ends. + "workflow_skills": ["living-ui-creator"], + }, ) ) @@ -1669,7 +1692,12 @@ async def start_development_run(self, project_id: str) -> Optional[str]: description=task_instruction, priority=50, session_id=session.id, - payload={"project_id": project_id}, + payload={ + "project_id": project_id, + # This run writes code, so it needs the build skill — + # loaded now, unloaded when the run ends. + "workflow_skills": ["living-ui-creator"], + }, ) ) diff --git a/app/living_ui/v2_runner.py b/app/living_ui/v2_runner.py index b4367bf6..f8965617 100644 --- a/app/living_ui/v2_runner.py +++ b/app/living_ui/v2_runner.py @@ -176,6 +176,29 @@ async def kit_sync(self, project_dir: Path) -> None: if code != 0: raise RuntimeError(f"kit-sync failed:\n{out[-2000:]}") + async def adapter_sync(self, project_dir: Path) -> None: + """Bring the project's system pb_hooks up to the current adapter. + + Without this, the A2APP layer reaches only apps that were installed or + imported AFTER it shipped: `kit_sync` is called from those two paths + alone, so an app a user already had would never gain the write guard, + the identity endpoint or `describe`, however often they opened it. + + Safe on every launch: it replaces only the tooling-owned hook files + (agent-authored `ops.pb.js` and friends are untouched), needs no + rebuild because PocketBase reads hooks at runtime, and is idempotent. + Failure is NON-FATAL — an app that cannot be upgraded should still + start, just without the newer guard. + """ + code, out = await self._run( + self._cli("adapter-sync", str(project_dir)), timeout=GATE_TIMEOUT_S + ) + if code != 0: + logger.warning( + f"[LIVING_UI:V2] adapter-sync failed for {project_dir.name} " + f"(app will run with its existing adapter): {out[-300:]}" + ) + async def pb_binary(self) -> Path: code, out = await self._run(self._cli("pb", "path"), timeout=300) if code != 0: @@ -247,6 +270,40 @@ async def ensure_superuser(self, project_dir: Path) -> None: except Exception as e: logger.warning(f"[LIVING_UI:V2] could not persist .superuser: {e}") + def ensure_agent_token(self, project_dir: Path) -> str: + """Guarantee the project has an agent token, and return it. + + This is the credential a NON-BROWSER client presents to write: the lui + CLI, CraftBot, or a third-party agent (spec A2APP-PLAN Phase 2 C4). + + Threat model, stated plainly: the file is 0600 but any local process + running as this user can read it, so it is not a boundary against local + code. It is the same model Home Assistant and Obsidian's local API use, + and it is the right one for a loopback app. What it buys is a real + credential that can be handed to an external agent, and the foundation + for tightening collection rules (A4) and for remote access later. + + The app's own frontend does NOT need it — browsers always send Origin + on writes, and a loopback Origin is trusted by the system middleware. + """ + import secrets + + token_file = project_dir / ".agent-token" + try: + existing = token_file.read_text(encoding="utf-8").strip() + if existing: + return existing + except Exception: + pass + + token = secrets.token_urlsafe(32) + try: + token_file.write_text(token + "\n", encoding="utf-8") + token_file.chmod(0o600) + except Exception as e: + logger.warning(f"[LIVING_UI:V2] could not persist .agent-token: {e}") + return token + async def start( self, project_dir: Path, port: int, bridge_token: str = "" ) -> subprocess.Popen: @@ -255,6 +312,11 @@ async def start( pb_dir = project_dir / "pb" # Must happen BEFORE serve, or PocketBase opens its setup page. await self.ensure_superuser(project_dir) + # The credential non-browser clients present to write (Phase 2 C4). + self.ensure_agent_token(project_dir) + # Upgrade the in-app A2APP layer. This is the ONLY path that reaches an + # app the user already had — install and import cover new arrivals only. + await self.adapter_sync(project_dir) logs_dir = project_dir / "logs" logs_dir.mkdir(parents=True, exist_ok=True) log_file = open(logs_dir / "pocketbase.log", "a") diff --git a/living-ui-v2/.gitignore b/living-ui-v2/.gitignore index b39272c7..22d7e846 100644 --- a/living-ui-v2/.gitignore +++ b/living-ui-v2/.gitignore @@ -16,3 +16,4 @@ logs/ # Local env/secrets .env .superuser +.agent-token diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js new file mode 100644 index 00000000..b8f97ba1 --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js @@ -0,0 +1,78 @@ +/// +/** + * SYSTEM HOOKS — managed by tooling, never edited by agents (spec P1). + * A2APP Phase 1 — see spec/A2APP-PLAN.md §6 Phase 1 Track B. + * + * GET /api/_a2app identity: app id, adapter + schema version, server clock + * routerUse write guard — runs BEFORE PocketBase coerces the body, + * which is the only place bad values are still visible + * record hooks attribution log + "did it actually land" backstop + * + * IMPORTANT: hook callbacks run in isolated VMs that CANNOT see this file's own + * scope — calling a function defined here fails with "ReferenceError: is + * not defined". Every callback must reach shared logic through require(). + * Keep the bodies below trivial. + */ + +routerUse((e) => { + const a2 = require(`${__hooks}/_a2app_lib.js`); + return a2.guardRequest(e); +}); + +routerAdd('GET', '/api/_a2app', (e) => { + const a2 = require(`${__hooks}/_a2app_lib.js`); + let manifest = {}; + try { + manifest = JSON.parse(toString($os.readFile($filepath.join(__hooks, '..', '..', 'manifest.json')))); + } catch (err) { + manifest = {}; + } + return e.json(200, { + a2app: true, + protocol: '1.0', + adapterVersion: a2.ADAPTER_VERSION, + app: { + id: manifest.id || null, + name: manifest.name || null, + livingUIVersion: manifest.livingUIVersion || null, + kitVersion: manifest.kitVersion || null, + pbVersion: manifest.pbVersion || null, + }, + schemaVersion: a2.schemaVersion(e.app), + serverNow: a2.serverNowIso(), + serverTzOffsetMinutes: -new Date().getTimezoneOffset(), + }); +}); + +/** + * GET /api/_a2app/describe — the app's data model and verb surface, generated + * from the LIVE schema so it cannot drift from what the app actually is. + * + * This is the endpoint an agent reads instead of guessing collection names or + * reverse-engineering migrations, which is what the originating incident did + * for three turns before giving up and reading source. + */ +routerAdd('GET', '/api/_a2app/describe', (e) => { + const a2 = require(`${__hooks}/_a2app_lib.js`); + const described = a2.describeApp(e.app); + return e.json(200, { + a2app: true, + protocol: '1.0', + adapterVersion: a2.ADAPTER_VERSION, + schemaVersion: a2.schemaVersion(e.app), + serverNow: a2.serverNowIso(), + entities: described.entities, + operations: described.operations, + conventions: described.conventions, + }); +}); + +onRecordCreateRequest((e) => { + const a2 = require(`${__hooks}/_a2app_lib.js`); + return a2.guardRecord(e, 'create'); +}); + +onRecordUpdateRequest((e) => { + const a2 = require(`${__hooks}/_a2app_lib.js`); + return a2.guardRecord(e, 'update'); +}); diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js new file mode 100644 index 00000000..f5e50245 --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js @@ -0,0 +1,499 @@ +/// +/** + * SYSTEM MODULE — managed by tooling, never edited by agents (spec P1). + * A2APP PocketBase adapter. Require inside handlers: + * + * const a2 = require(`${__hooks}/_a2app_lib.js`); + * + * This file is deliberately thin. All validation rules live in + * `_a2app_rules.js`, which is pure and shared with every other adapter so that + * a PocketBase app and a Django app enforce identical rules (spec §10b). + * This file only: + * 1. maps PocketBase field types onto the protocol vocabulary + * 2. calls the rules + * 3. renders violations as BadRequestError + * + * ── Why the guard runs as ROUTER MIDDLEWARE, not a record hook (spec §3.15) ── + * By the time onRecordCreateRequest runs, PocketBase has already coerced the + * body against the schema: + * sent {"due_date":"tomorrow"} -> hook sees a DateTime object, String() === "" + * sent {"due_date":""} -> hook sees exactly the same thing + * sent {"position":"abc"} -> hook sees 0 + * sent {"archived":"maybe"} -> hook sees false + * So a record hook cannot tell "clear this date" from "I sent garbage", and + * cannot see bad numbers or booleans at all. Middleware runs before coercion + * and sees the raw JSON — the only place these are still visible. + * + * ── What PocketBase already rejects (not re-implemented here) ── + * bad relation id -> 400 validation_missing_rel_records + * missing required -> 400 validation_required + */ + +var ADAPTER_VERSION = '1.6.0'; +var RECORD_PATH = /^\/api\/collections\/([^\/]+)\/records(\/([^\/?]+))?$/; + +function rules() { + return require(__hooks + '/_a2app_rules.js'); +} + +/* ------------------------------------------------- PocketBase type mapping */ + +/** PocketBase field type -> A2APP protocol type (spec §5.2). */ +function protocolType(pbType, multiple) { + switch (pbType) { + case 'text': + case 'editor': + case 'email': + case 'url': + case 'password': + return 'string'; + case 'number': + return 'number'; + case 'bool': + return 'boolean'; + case 'date': + case 'autodate': + return 'datetime'; + case 'select': + return multiple ? 'list' : 'enum'; + case 'relation': + return multiple ? 'list' : 'ref'; + case 'file': + return 'binary'; + case 'json': + case 'geoPoint': + return 'json'; + default: + return 'string'; + } +} + +/** Normalise a collection's fields into the rules module's contract. + * `f.type` is a METHOD in 0.39.7 — reading it as a property yields a Go func + * value, and e.json() then emits a zero-length 200 with the error only in the + * server log (spec §3.16). */ +function fieldsOf(app, collection) { + var R = rules(); + var out = []; + var raw = collection.fields; + for (var i = 0; i < raw.length; i++) { + var f = raw[i]; + var pbType = typeof f.type === 'function' ? String(f.type()) : String(f.type); + var maxSelect = f.maxSelect ? Number(f.maxSelect) : 1; + var multiple = (pbType === 'select' || pbType === 'relation' || pbType === 'file') && maxSelect > 1; + + // PocketBase marks several AUTH fields `system` even though they are + // legitimately writable — `email` above all. Trusting the flag blindly + // both rejects every signup in the guard AND advertises email as + // read-only in `describe`, so a client would never try. `tokenKey` and + // `id` are the genuinely non-writable ones. + var name = String(f.name); + var authWritable = + String(collection.type) === 'auth' && + (name === 'email' || name === 'username' || name === 'emailVisibility' || name === 'verified'); + + var entry = { + name: name, + type: protocolType(pbType, multiple), + pbType: pbType, + required: !!f.required, + readOnly: (!!f.system && !authWritable) || pbType === 'autodate', + writeOnly: pbType === 'password', + max: f.max ? Number(f.max) : 0, + }; + if (pbType === 'select') entry.values = (f.values || []).map(String); + if (pbType === 'relation') { + entry.maxSelect = maxSelect; + try { + entry.target = String(app.findCollectionByNameOrId(f.collectionId).name); + } catch (err) { + entry.target = null; + } + } + entry.dayKey = R.isDayKeyField(entry); + out.push(entry); + } + return out; +} + +/* --------------------------------------------------------------- rendering */ + +function serverNowIso() { + return new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z'); +} + +/** Recognise our own rejections coming back through a catch. PocketBase + * capitalises the first letter of error messages, so a case-sensitive prefix + * match silently fails — check the marker first, casefold second (spec §3.16). */ +function isA2appError(err) { + if (!err) return false; + if (err.__a2app === true) return true; + return String(err.message || '').toLowerCase().indexOf('rejected by a2app') === 0; +} + +/** PocketBase renders the 2nd BadRequestError arg as a per-field error map. */ +function rejection(message, violations) { + var detail = {}; + for (var i = 0; i < violations.length; i++) { + detail[violations[i].field] = violations[i].code; + } + var err = new BadRequestError(message, detail); + err.__a2app = true; + return err; +} + +/* -------------------------------------------------------------- idempotency */ + +/** + * Agents retry. Without a key, a write that succeeded but whose response was + * lost to a timeout gets replayed and duplicates the record — and the incident + * log shows this model retrying a byte-identical command 66 seconds later, so + * it is not hypothetical. + * + * Semantics are REJECT-DUPLICATE, not replay-response: a repeat returns 409 + * with the id the first attempt created. That is what a caller actually needs + * ("it already happened, here it is") and it avoids having to capture and + * store whole response bodies. + * + * State lives in `$app.store()` — verified to persist across requests. It is + * in-memory, so it is lost on restart and not shared across processes; both are + * acceptable for a single-process loopback app, and a retry after a restart is + * a different situation anyway. Entries older than a day are ignored. + */ +var IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; + +function idempotencyKey(e, collectionName) { + var supplied = ''; + try { + var headers = e.requestInfo().headers || {}; + supplied = String(headers.idempotency_key || ''); + } catch (err) { + supplied = ''; + } + if (supplied === '') { + try { + supplied = String(e.request.header.get('Idempotency-Key') || ''); + } catch (err) { + supplied = ''; + } + } + if (supplied === '') return null; + // Keyed on collection + supplied value only. `e.request.method` and + // `e.request.url.path` are unreachable from a RECORD event (same limitation + // that forces agentIdOf through requestInfo().headers), so including them + // made the middleware and the record hook compute different keys — and the + // duplicate check silently never matched. + return 'a2app_idem:' + collectionName + ':' + supplied.slice(0, 200); +} + +function idempotencySeen(key) { + if (!key) return null; + try { + var entry = $app.store().get(key); + if (!entry) return null; + if (Date.now() - Number(entry.ts || 0) > IDEMPOTENCY_TTL_MS) return null; + return entry; + } catch (err) { + return null; + } +} + +function idempotencyRecord(key, recordId) { + if (!key) return; + try { + $app.store().set(key, { id: String(recordId), ts: Date.now() }); + } catch (err) { + /* never break a write over bookkeeping */ + } +} + +/* ------------------------------------------------------------ entry points */ + +/** Middleware: validate writes to /api/collections/{c}/records before coercion. */ +function guardRequest(e) { + var method, path; + try { + method = String(e.request.method || '').toUpperCase(); + path = String((e.request.url && e.request.url.path) || ''); + } catch (err) { + return e.next(); + } + if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT') return e.next(); + + var m = path.match(RECORD_PATH); + if (!m) return e.next(); + + // A replayed Idempotency-Key never re-executes. + var idemKey = idempotencyKey(e, m[1]); + var seen = idempotencySeen(idemKey); + if (seen) { + return e.json(409, { + a2app: true, + ok: false, + code: 'duplicate_request', + id: seen.id, + message: + 'Rejected by a2app (duplicate_request): this Idempotency-Key already created ' + + seen.id + '. The original write succeeded — do not retry.', + }); + } + + var collection, body; + try { + collection = e.app.findCollectionByNameOrId(m[1]); + body = e.requestInfo().body; + } catch (err) { + return e.next(); // unknown collection / unparseable body — let PocketBase answer + } + if (!collection || !body || typeof body !== 'object' || Array.isArray(body)) return e.next(); + + // A validation layer must never take the app down: only a2app rejections + // propagate; anything unexpected is logged and the request proceeds. + try { + var R = rules(); + var allow = { id: 1 }; + if (String(collection.type) === 'auth') { + // PocketBase flags several auth fields as `system`, including `email` — + // but they ARE writable at signup. Without this, the read-only check + // rejects every registration. (Found by testing the positive path; the + // negative path passed happily while signup was broken.) + allow.email = 1; + allow.username = 1; + allow.password = 1; + allow.passwordConfirm = 1; + allow.oldPassword = 1; + allow.verified = 1; + allow.emailVisibility = 1; + } + var violations = R.validate(fieldsOf(e.app, collection), body, { allow: allow }); + if (violations.length) { + // RETURN rather than throw. PocketBase normalises the `data` map of any + // thrown error recursively — every leaf becomes + // {code:"validation_invalid_value", message:"Invalid value."} — so a + // machine-readable code cannot survive the error path, and clients would + // be left regexing prose. Verified for both ApiError and + // BadRequestError. Returning a response from middleware keeps the shape. + var now = serverNowIso(); + var first = violations[0]; + return e.json(400, { + a2app: true, + ok: false, + code: first.code, + field: first.field, + expected: first.expected, + got: first.got, + serverNow: now, + message: R.describeViolation(first, now), + violations: violations, + }); + } + } catch (err) { + if (isA2appError(err)) throw err; + console.log('[a2app] guard skipped on ' + path + ': ' + (err && err.message)); + } + return e.next(); +} + +/** Record hook: attribution log + the "did it land" backstop. */ +function guardRecord(e, op) { + var result = e.next(); + try { + var R = rules(); + var body = e.requestInfo().body || {}; + var collection = e.record.collection(); + var record = e.record; + var lost = R.divergences(fieldsOf(e.app, collection), body, function (name) { + return record.get(name); + }); + + logAction({ + ts: serverNowIso(), + agent: agentIdOf(e), + op: op, + collection: String(collection.name), + recordId: String(record.id), + requested: Object.keys(body), + lost: lost, + verdict: lost.length ? 'incomplete' : 'ok', + }); + + if (!lost.length) { + idempotencyRecord(idempotencyKey(e, String(collection.name)), record.id); + } + + if (lost.length) { + var violations = []; + for (var i = 0; i < lost.length; i++) { + violations.push({ field: lost[i].field, code: 'not_stored' }); + } + throw rejection(R.describeIncomplete(lost), violations); + } + } catch (err) { + if (isA2appError(err)) throw err; + console.log('[a2app] backstop skipped: ' + (err && err.message)); + } + return result; +} + +/* --------------------------------------------------- attribution + identity */ + +function logAction(entry) { + try { + var dir = $filepath.join(__hooks, '..', '..', 'logs'); + $os.mkdirAll(dir, 0o755); + var file = $filepath.join(dir, 'agent-actions.jsonl'); + var existing = ''; + try { + existing = toString($os.readFile(file)); + } catch (err) { + existing = ''; + } + $os.writeFile(file, existing + JSON.stringify(entry) + '\n', 0o644); + } catch (err) { + // Logging must never break a write. + } +} + +/** Header access differs by event type: router events expose + * e.request.header.get(); record events reliably expose it only through + * requestInfo().headers, lower-cased with underscores (spec §3.16). */ +function agentIdOf(e) { + try { + var h = e.requestInfo().headers || {}; + if (h.x_lui_agent) return String(h.x_lui_agent).slice(0, 120); + } catch (err) { + /* fall through */ + } + try { + var id = e.request.header.get('X-LUI-Agent'); + if (id) return String(id).slice(0, 120); + } catch (err) { + /* fall through */ + } + return 'unknown'; +} + +/** The label field a human would use to name a record of this entity. Clients + * need it to resolve "To Do" to an id. Note that across the shipped apps NO + * relation field has a unique index on its label, so a client MUST treat a + * multi-match as ambiguous rather than picking one (spec §3.6). */ +function labelFieldOf(fields) { + var names = []; + for (var i = 0; i < fields.length; i++) names.push(fields[i].name); + var preferred = ['title', 'name', 'label']; + for (var p = 0; p < preferred.length; p++) { + if (names.indexOf(preferred[p]) >= 0) return preferred[p]; + } + for (var j = 0; j < fields.length; j++) { + if (fields[j].type === 'string' && fields[j].required && !fields[j].readOnly) { + return fields[j].name; + } + } + return null; +} + +/** + * The app's data model and verb surface, generated from the LIVE schema so it + * can never drift from what the app actually is (spec Phase 2 C2). + * + * Types are the A2APP protocol vocabulary, not PocketBase's — that is what + * lets a client written against a PocketBase app work unchanged against any + * other backend once its adapter exists (spec §5.2). + */ +function describeApp(app) { + var entities = {}; + var collections = app.findAllCollections('base', 'auth'); + for (var i = 0; i < collections.length; i++) { + var c = collections[i]; + var name = String(c.name); + if (name.indexOf('_') === 0) continue; + + var fields = fieldsOf(app, c); + var out = {}; + for (var j = 0; j < fields.length; j++) { + var f = fields[j]; + if (f.writeOnly) continue; // never advertise a secret + var entry = { type: f.type }; + if (f.required) entry.required = true; + if (f.readOnly) entry.readOnly = true; + if (f.max) entry.max = f.max; + if (f.values) entry.values = f.values; + if (f.target) entry.entity = f.target; + if (f.dayKey) entry.format = 'YYYY-MM-DD'; + out[f.name] = entry; + } + entities[name] = { + label: labelFieldOf(fields), + auth: String(c.type) === 'auth', + fields: out, + records: '/api/collections/' + name + '/records', + }; + } + + var operations = []; + try { + var manifest = JSON.parse( + toString($os.readFile($filepath.join(__hooks, '..', '..', 'operations.json'))) + ); + operations = manifest.operations || []; + } catch (err) { + operations = []; + } + + return { + entities: entities, + operations: operations, + // How to drive this app WELL — not just legally. These rules used to live + // only in a CraftBot skill, which meant every other agent drove the app + // without them: writing collections directly when an operation existed, + // firing destructive ops unasked, reaching into internal fields. Knowledge + // every client needs belongs in the app, not in one vendor's toolchain. + conventions: { + read: 'GET {entity.records}?filter=...&sort=...&perPage=N — PocketBase filter syntax', + write: 'POST {entity.records} | PATCH {entity.records}/{id} | DELETE {entity.records}/{id}', + datetime: 'ISO 8601, e.g. "2026-07-30" or "2026-07-30 00:00:00.000Z". Relative words are NOT accepted — resolve them client-side, where a real clock and timezone database exist.', + ref: 'Pass a record id. Resolve a human label via GET {target.records}?filter=(label="…"); more than one match is ambiguous, not a choice.', + operations: + 'Check `operations` first and prefer a declared one over writing collections directly — it encodes rules the raw API does not.', + destructive: + 'An operation marked `destructive` changes or deletes data irreversibly. Confirm with the user before running it.', + scope: + "Write only what the app's own UI would let a user write. Do not set internal or server-managed fields to work around a limitation.", + limits: + 'If the app cannot express what was asked, say so plainly. Do not approximate it into a field that means something else.', + agent: 'Send X-LUI-Agent: on writes; it is recorded in the app’s action log.', + errors: 'Rejections carry a machine code in `data.` and a full explanation in `message`.', + }, + }; +} + +/** Fingerprint of the schema so clients can detect a stale cached describe. */ +function schemaVersion(app) { + var parts = []; + var cols = app.findAllCollections('base', 'auth'); + for (var i = 0; i < cols.length; i++) { + var c = cols[i]; + if (String(c.name).indexOf('_') === 0) continue; + var fields = fieldsOf(app, c); + var names = []; + for (var j = 0; j < fields.length; j++) names.push(fields[j].name + ':' + fields[j].type); + names.sort(); + parts.push(String(c.name) + '(' + names.join(',') + ')'); + } + parts.sort(); + var joined = parts.join(';'); + var h = 5381; + for (var k = 0; k < joined.length; k++) h = ((h * 33) ^ joined.charCodeAt(k)) >>> 0; + return 'sv_' + h.toString(16); +} + +module.exports = { + ADAPTER_VERSION: ADAPTER_VERSION, + describeApp: describeApp, + fieldsOf: fieldsOf, + protocolType: protocolType, + guardRequest: guardRequest, + guardRecord: guardRecord, + schemaVersion: schemaVersion, + serverNowIso: serverNowIso, +}; diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js new file mode 100644 index 00000000..2d729e1d --- /dev/null +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js @@ -0,0 +1,251 @@ +/** + * A2APP validation rules — PURE. No PocketBase, no Node, no I/O, no globals. + * + * This module is shared verbatim by every A2APP adapter so that a PocketBase + * app and a Django app enforce IDENTICAL rules (spec A2APP-PLAN §10b). It is + * the reason the guard can move to the Phase 4 runtime without being rewritten. + * + * An adapter's only jobs are: + * 1. map its backend's field types onto the protocol vocabulary below + * 2. call validate() / divergences() + * 3. render the returned violations as its transport's error + * + * It deliberately RETURNS violations rather than throwing: throwing requires a + * transport-specific error class (BadRequestError in PocketBase, an HTTP + * response in the runtime), which is exactly the coupling this file avoids. + * + * ── Protocol type vocabulary (spec A2APP-PLAN §5.2) ── + * string · number · boolean · datetime · duration · enum · ref + * list · list · json · binary + * + * ── Normalised field contract expected by validate() ── + * { name, type, required?, readOnly?, writeOnly?, max?, values?, target?, dayKey? } + */ + +var RULES_VERSION = '1.0.0'; + +/* ------------------------------------------------------------------ helpers */ + +function isBlank(v) { + return v === null || v === undefined || v === ''; +} + +/** Regex alone accepts 2026-13-45, so check the calendar too. */ +function isValidYmd(y, m, d) { + if (m < 1 || m > 12 || d < 1) return false; + var lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + var max = lengths[m - 1]; + if (m === 2 && y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)) max = 29; + return d <= max; +} + +function looksLikeDate(v) { + if (typeof v !== 'string') return false; + var m = v.match(/^(\d{4})-(\d{2})-(\d{2})([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/); + return !!m && isValidYmd(Number(m[1]), Number(m[2]), Number(m[3])); +} + +function isDayKeyValue(v) { + if (typeof v !== 'string') return false; + var m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/); + return !!m && isValidYmd(Number(m[1]), Number(m[2]), Number(m[3])); +} + +/** + * Day-keys stored as plain text to dodge timezone drift — e.g. + * crm-system.tasks.due and habit-tracker.entries.day are `text, max 10` with + * the comment "Day key as plain text YYYY-MM-DD". + * + * No type check can protect these: writing "tomorrow" stores it verbatim, the + * field LOOKS populated, and because comparison is lexical the row is + * permanently invisible to `due <= "2026-07-29"` ("t" > "2"). Strictly worse + * than the empty string. Hence a name+width convention (spec D3). + */ +function isDayKeyField(field) { + if (field.type !== 'string') return false; + var max = field.max || 0; + if (max <= 0 || max > 12) return false; + return /^(due|day|date)$|_(date|day)$/i.test(field.name); +} + +function fieldMap(fields) { + var m = {}; + for (var i = 0; i < fields.length; i++) m[fields[i].name] = fields[i]; + return m; +} + +function writableNames(fields) { + var out = []; + for (var i = 0; i < fields.length; i++) { + if (!fields[i].readOnly) out.push(fields[i].name); + } + return out; +} + +/* --------------------------------------------------------------- validation */ + +function violation(code, field, expected, got) { + return { code: code, field: field, expected: expected, got: got === undefined ? null : got }; +} + +/** + * Validate a RAW request body against normalised fields. + * + * IMPORTANT: the body must be raw — the values as the client sent them. Any + * backend that coerces before validation defeats this entirely. (PocketBase + * coerces before its record hooks run, which is why its adapter validates in + * router middleware instead. See spec §3.15.) + * + * opts.allow — extra keys accepted without being fields (e.g. 'id', auth fields) + * + * Returns [] when the body is acceptable. + */ +function validate(fields, body, opts) { + var options = opts || {}; + var allow = options.allow || {}; + var map = fieldMap(fields); + var writable = writableNames(fields); + var out = []; + + var keys = Object.keys(body); + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + if (allow[key]) continue; + + var field = map[key]; + + // Silently dropped by most backends, and absent from the response, so the + // caller cannot tell it was ignored. + if (!field) { + out.push(violation('unknown_field', key, 'one of: ' + writable.join(', '), body[key])); + continue; + } + if (field.readOnly) { + out.push(violation('read_only_field', key, 'not writable (server-managed)', body[key])); + continue; + } + + var value = body[key]; + // Blank means "clear this field" — a legitimate operation. Only + // distinguishable from garbage because we see the raw value. + if (isBlank(value)) continue; + + if (field.type === 'datetime' && !looksLikeDate(value)) { + out.push(violation('invalid_date', key, 'an ISO 8601 date', value)); + continue; + } + if (field.dayKey && !isDayKeyValue(value)) { + out.push(violation('invalid_daykey', key, 'a day key "YYYY-MM-DD"', value)); + continue; + } + // A boolean or number landing in a text field. Sounds unlikely; it happened + // on the first day: the agent wrote `--color #ff6b35`, the SHELL treated + // `#` as a comment and dropped the value, the CLI saw a bare `--color` and + // sent `true`, and PocketBase stored the string "true" as the colour. Every + // other type was checked, so the one unchecked type is where it landed. + if (field.type === 'string' && typeof value !== 'string') { + out.push(violation('invalid_string', key, 'text', value)); + continue; + } + if (field.type === 'number' && typeof value !== 'number') { + if (typeof value !== 'string' || value.trim() === '' || isNaN(Number(value))) { + out.push(violation('invalid_number', key, 'a number', value)); + continue; + } + } + if (field.type === 'boolean' && typeof value !== 'boolean') { + if (value !== 'true' && value !== 'false') { + out.push(violation('invalid_boolean', key, 'true or false', value)); + continue; + } + } + if (field.type === 'enum' && field.values && field.values.length) { + var found = false; + for (var v = 0; v < field.values.length; v++) { + if (String(field.values[v]) === String(value)) found = true; + } + if (!found) { + out.push(violation('invalid_enum', key, 'one of: ' + field.values.join(' | '), value)); + continue; + } + } + } + return out; +} + +/** + * Backstop: which non-blank requested values failed to land? + * + * `read(name)` returns the stored value for a field. Deliberately conservative + * — only flags "asked for something, stored nothing" — so it never + * false-positives on a legitimate false/0/"" write, nor on a backend + * normalising 2026-07-30T00:00:00+01:00 to 2026-07-29T23:00:00Z. + */ +function divergences(fields, body, read) { + var map = fieldMap(fields); + var out = []; + var keys = Object.keys(body); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var field = map[key]; + if (!field || field.readOnly) continue; + var requested = body[key]; + if (isBlank(requested) || String(requested) === '') continue; + var stored; + try { + stored = read(key); + } catch (err) { + stored = null; + } + if (isBlank(stored) || String(stored) === '') { + out.push({ field: key, type: field.type, stored: String(stored) }); + } + } + return out; +} + +/* ----------------------------------------------------------------- messages */ + +/** One human+machine readable sentence. Adapters should not invent their own — + * identical rules must produce identical text on every backend. + * NOTE: starts with a capitalised word on purpose; PocketBase capitalises the + * first letter of error messages, which silently breaks prefix matching. */ +function describeViolation(v, serverNow) { + var msg = + 'Rejected by a2app (' + + v.code + + '): field "' + + v.field + + '" expects ' + + v.expected + + '; got ' + + JSON.stringify(v.got); + if (v.code === 'invalid_date' || v.code === 'invalid_daykey') { + if (serverNow) msg += '. Example: "' + String(serverNow).slice(0, 10) + '"'; + } + if (serverNow) msg += '. Server time is ' + serverNow; + return msg + '.'; +} + +function describeIncomplete(lost) { + var names = []; + for (var i = 0; i < lost.length; i++) names.push(lost[i].field); + return ( + 'Rejected by a2app (write_incomplete): the database did not store ' + + names.join(', ') + + '. Do NOT report this as done.' + ); +} + +module.exports = { + RULES_VERSION: RULES_VERSION, + isBlank: isBlank, + isValidYmd: isValidYmd, + looksLikeDate: looksLikeDate, + isDayKeyValue: isDayKeyValue, + isDayKeyField: isDayKeyField, + validate: validate, + divergences: divergences, + describeViolation: describeViolation, + describeIncomplete: describeIncomplete, +}; diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js index 9b24019b..02f2809a 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js @@ -1,22 +1,247 @@ /// /** * SYSTEM HOOKS — managed by tooling, never edited by agents (spec P1). + * - origin guard CORS + frame-ancestors (spec A2APP-PLAN Phase 1 A1/A2) * - GET /api/_ops operations manifest discovery (spec O4) * - POST /api/_console frontend console relay sink (spec K8/D12) * (Health is PocketBase's built-in /api/health.) */ -// Allow embedding in the CraftBot shell: PocketBase sets -// X-Frame-Options: sameorigin by default, which blocks the host iframe. -// Loopback-bound local apps are safe to frame. +/** + * ORIGIN GUARD. + * + * PocketBase answers every request with `Access-Control-Allow-Origin: *`, and + * these apps ship with open collection rules because they bind loopback. The + * assumption that "loopback is safe" is wrong: loopback is reachable by every + * page the user visits, so with a wildcard ACAO any website could read AND + * write the user's data — verified against a running app from a foreign origin. + * This is the same class as the Jan and drawio-mcp advisories. + * + * CORS is enforced by the browser, so the fix is the HEADER, not the body: a + * page from evil.example no longer receives an ACAO it can use, and its + * preflight for a destructive route is no longer approved. Direct clients + * (curl, the CLI, an agent) are unaffected — they were never the threat. + * + * Policy: loopback origins only. That covers the app's own frontend + * (same-origin) and the CraftBot shell, and excludes the public internet. + * + * NOTE FOR EDITORS: hook callbacks run in isolated VMs that CANNOT see this + * file's scope — a callback referencing a const or function declared out here + * dies with "ReferenceError: is not defined". Everything below is + * therefore inlined per callback, deliberately, including the origin pattern. + */ + +routerUse((e) => { + // Must run BEFORE e.next(): headers are flushed with the first body byte, so + // post-next mutation is a no-op. PocketBase's own setters run before user + // middleware, so changes made here win. + const ALLOWED_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/; + var headers = e.response.header(); + var origin = ''; + try { + origin = String(e.request.header.get('Origin') || ''); + } catch (err) { + origin = ''; + } + + // Framing: replace the blanket X-Frame-Options delete (which allowed ANY + // site to frame the app) with a policy that names who may. + headers.del('X-Frame-Options'); + headers.set( + 'Content-Security-Policy', + "frame-ancestors 'self' http://127.0.0.1:* http://localhost:*" + ); + + if (origin === '') return e.next(); // not a browser cross-origin request + + if (ALLOWED_ORIGIN.test(origin)) { + headers.set('Access-Control-Allow-Origin', origin); + headers.set('Vary', 'Origin'); + return e.next(); + } + + // Foreign origin: withhold every CORS grant, so the browser refuses to + // expose the response. This is what stops a drive-by READ. + headers.del('Access-Control-Allow-Origin'); + headers.del('Access-Control-Allow-Methods'); + headers.del('Access-Control-Allow-Headers'); + headers.del('Access-Control-Allow-Credentials'); + headers.del('Access-Control-Expose-Headers'); + + // Withholding headers is NOT enough for writes. PocketBase answers the + // OPTIONS preflight itself, before user middleware runs, so it still returns + // `Access-Control-Allow-Origin: *` and the browser goes on to send the real + // request — the response is unreadable, but the write already happened. + // So mutating methods from a foreign origin are refused outright. + var method = ''; + try { + method = String(e.request.method || '').toUpperCase(); + } catch (err) { + method = ''; + } + if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { + return e.json(403, { + ok: false, + error: 'forbidden origin: ' + origin, + hint: 'This app only accepts writes from loopback origins.', + }); + } + return e.next(); +}); + +/** + * OPS AUTH GUARD (spec A2APP-PLAN Phase 1 A7). + * + * Op routes execute through `e.app.*`, which bypasses PocketBase's API rules — + * the layer REQUIREMENTS B6 designates as the security boundary. In a + * multi-user app that is a hole big enough to defeat the app's own rules: + * crm-system sets `@request.auth.id != ""` on all 15 collections and then + * exposes an unauthenticated arbitrary-recipient Gmail send at + * POST /api/ops/emails/send. + * + * Enforcing here rather than in each app's ops.pb.js means it cannot be + * forgotten when an agent adds a route, and it ships with kit-sync. + * + * `authMode: none` apps are unaffected — they have no principal to check, and + * the origin guard above is what protects them. + */ +routerUse((e) => { + var path = ''; + try { + path = String((e.request.url && e.request.url.path) || ''); + } catch (err) { + return e.next(); + } + if (path.indexOf('/api/ops/') !== 0) return e.next(); + + var authMode = 'none'; + try { + var manifest = JSON.parse( + toString($os.readFile($filepath.join(__hooks, '..', '..', 'manifest.json'))) + ); + authMode = String(manifest.authMode || 'none'); + } catch (err) { + authMode = 'none'; + } + if (authMode !== 'multi-user') return e.next(); + + var authed = false; + try { + if (e.auth) authed = true; + } catch (err) { + /* fall through */ + } + if (!authed) { + try { + var info = e.requestInfo(); + if (info && info.auth) authed = true; + } catch (err) { + /* fall through */ + } + } + if (!authed) { + return e.json(401, { + ok: false, + error: 'authentication required', + hint: 'This app is multi-user; operations require a signed-in principal.', + }); + } + return e.next(); +}); + +/** + * AGENT TOKEN (spec A2APP-PLAN Phase 2 C4). + * + * A non-browser client that writes must present the project's agent token. + * The app's own frontend does not need it: browsers always send `Origin` on a + * write, and a loopback `Origin` is already trusted by the guard above. So the + * rule is precisely "programmatic callers carry a credential", which is what + * makes handing access to a third-party agent a deliberate act. + * + * Not a defence against local processes — anything running as this user can + * read the 0600 file. That is the correct model for a loopback app (Home + * Assistant and Obsidian's local API work the same way); what it buys is a + * real credential to hand out, and the precondition for tightening collection + * rules and for remote access later. + */ routerUse((e) => { - // Must run BEFORE e.next(): headers are flushed with the first body byte, - // so post-next deletion is a no-op. PB's own SAMEORIGIN setter runs before - // user middleware, so a pre-next delete removes it for good. - e.response.header().del('X-Frame-Options'); + var method = ''; + var path = ''; + var origin = ''; + try { + method = String(e.request.method || '').toUpperCase(); + path = String((e.request.url && e.request.url.path) || ''); + origin = String(e.request.header.get('Origin') || ''); + } catch (err) { + return e.next(); + } + if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT' && method !== 'DELETE') { + return e.next(); + } + if (path.indexOf('/api/collections/') !== 0 && path.indexOf('/api/ops/') !== 0) { + return e.next(); + } + // Browser traffic: already constrained to loopback origins by the guard. + if (origin !== '') return e.next(); + // PocketBase's own auth flows must stay reachable (sign-in, refresh). + if (path.indexOf('/auth-') > 0 || path.indexOf('/request-') > 0) return e.next(); + + var expected = ''; + try { + expected = toString($os.readFile($filepath.join(__hooks, '..', '..', '.agent-token'))).trim(); + } catch (err) { + expected = ''; + } + if (expected === '') return e.next(); // no token provisioned — do not lock the app out + + var presented = ''; + try { + presented = String(e.request.header.get('X-LUI-Token') || '').trim(); + } catch (err) { + presented = ''; + } + if (presented !== expected) { + return e.json(401, { + ok: false, + error: 'agent token required', + hint: 'Send X-LUI-Token: on writes.', + }); + } return e.next(); }); +/** + * RATE LIMITS (spec A2APP-PLAN Phase 1 A3). + * + * PocketBase ships with `rateLimits.enabled: false`. These apps bind loopback + * with open rules, so an unbounded request rate is both a local-DoS surface + * and free rein for anything brute-forcing record ids or auth. Applied at + * bootstrap so a fresh pb_data gets it without a migration. + * + * Limits are deliberately loose — high enough that a UI, an agent doing bulk + * work, and the CLI never notice, low enough that a runaway loop or a scan + * does. Tighten per app if needed. + */ +onBootstrap((e) => { + e.next(); + try { + const settings = $app.settings(); + if (settings.rateLimits.enabled) return; + settings.rateLimits.enabled = true; + settings.rateLimits.rules = [ + { label: '*:auth', maxRequests: 30, duration: 60 }, + { label: '/api/collections/', maxRequests: 1200, duration: 60 }, + { label: '/api/ops/', maxRequests: 300, duration: 60 }, + { label: '/api/_console', maxRequests: 120, duration: 60 }, + ]; + $app.save(settings); + console.log('[system] rate limits enabled'); + } catch (err) { + // Never block boot on this. + console.log('[system] could not enable rate limits: ' + err); + } +}); + routerAdd('GET', '/api/_ops', (e) => { const path = $filepath.join(__hooks, '..', '..', 'operations.json'); const raw = toString($os.readFile(path)); @@ -24,6 +249,20 @@ routerAdd('GET', '/api/_ops', (e) => { }); routerAdd('POST', '/api/_console', (e) => { + // Unauthenticated arbitrary disk write otherwise: any page could fill the + // user's disk 50 x 4000 chars at a time. Same-origin (the app's own + // frontend) or a loopback tool only. Inlined per the note at the top. + const ALLOWED_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/; + let origin = ''; + try { + origin = String(e.request.header.get('Origin') || ''); + } catch (err) { + origin = ''; + } + if (origin !== '' && !ALLOWED_ORIGIN.test(origin)) { + return e.json(403, { ok: false, error: 'forbidden origin' }); + } + const body = e.requestInfo().body; const entries = Array.isArray(body?.entries) ? body.entries : []; if (entries.length === 0) return e.json(200, { ok: true }); @@ -38,6 +277,9 @@ routerAdd('POST', '/api/_console', (e) => { } catch { // first write } + // Cap the file so an unbounded relay cannot grow without limit. + if (existing.length > 2 * 1024 * 1024) existing = existing.slice(-1024 * 1024); + const lines = entries .slice(0, 50) .map((x) => JSON.stringify({ ts: x.ts, level: x.level, message: String(x.message).slice(0, 4000) })) diff --git a/living-ui-v2/scripts/a2app-selfcheck.sh b/living-ui-v2/scripts/a2app-selfcheck.sh new file mode 100755 index 00000000..b9cdcd28 --- /dev/null +++ b/living-ui-v2/scripts/a2app-selfcheck.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# A2APP self-check — exercises every guarantee against a RUNNING app. +# +# ./a2app-selfcheck.sh [base-url] +# +# Creates and deletes its own records; leaves the app as it found it. +# Exits non-zero if any check fails. Safe to re-run. +set -uo pipefail + +PROJ="${1:?usage: a2app-selfcheck.sh [base-url]}" +PROJ="$(cd "$PROJ" && pwd)" +PORT="$(python3 -c "import json;print(json.load(open('$PROJ/manifest.json'))['port'])")" +BASE="${2:-http://127.0.0.1:$PORT}" +CLI="$(cd "$(dirname "${BASH_SOURCE[0]}")/../tools" && pwd)/src/cli.ts" +TOKEN="$(cat "$PROJ/.agent-token" 2>/dev/null || true)" + +PASS=0; FAIL=0; CREATED=() + +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' \033[31m✗\033[0m %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"; FAIL=$((FAIL+1)); } +head() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +# check +check() { case "$3" in *"$2"*) ok "$1";; *) bad "$1" "$2" "${3:0:120}";; esac; } + +json() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1',''))" 2>/dev/null; } +code() { curl -s -m 8 -o /dev/null -w '%{http_code}' "$@"; } +body() { curl -s -m 8 "$@"; } + +W=(-H 'Content-Type: application/json') +[ -n "$TOKEN" ] && W+=(-H "X-LUI-Token: $TOKEN") + +head "0. Reachability" +check "app responds" '200' "$(code "$BASE/api/health")" +IDENT="$(body "$BASE/api/_a2app")" +check "identity carries a2app marker" '"a2app":true' "$IDENT" +printf ' app=%s adapter=%s schema=%s\n' \ + "$(echo "$IDENT" | json 'protocol')" \ + "$(echo "$IDENT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["adapterVersion"])' 2>/dev/null)" \ + "$(echo "$IDENT" | json 'schemaVersion')" + +head "1. Describe (what an external agent reads)" +DESC="$(body "$BASE/api/_a2app/describe")" +check "entities present" '"entities"' "$DESC" +check "protocol types, not PB types" '"type":"ref"' "$(echo "$DESC" | tr -d ' ')" +check "conventions documented" '"conventions"' "$DESC" +check "day/date fields typed" 'datetime' "$DESC" + +# Pick a writable entity with a required ref, else fall back to any entity. +read -r ENTITY LABELF REFFIELD REFTARGET <<<"$(echo "$DESC" | python3 -c ' +import sys,json +d=json.load(sys.stdin)["entities"] +for name,e in d.items(): + if e.get("auth"): continue + ref=[k for k,f in e["fields"].items() if f.get("type")=="ref" and f.get("required")] + lab=e.get("label") + if ref and lab: + print(name, lab, ref[0], e["fields"][ref[0]]["entity"]); break +else: + print("", "", "", "")')" + +if [ -z "$ENTITY" ]; then + echo " (no entity with a required ref — skipping write checks)" +else + printf ' using entity=%s label=%s ref=%s->%s\n' "$ENTITY" "$LABELF" "$REFFIELD" "$REFTARGET" + REFVAL="$(body "$BASE/api/collections/$REFTARGET/records?perPage=1" | python3 -c "import sys,json;i=json.load(sys.stdin)['items'];print(i[0]['id'] if i else '')")" + + head "2. Write guard (the original bug)" + R="$(body -X POST "$BASE/api/collections/$ENTITY/records" "${W[@]}" -d "{\"$LABELF\":\"selfcheck\",\"$REFFIELD\":\"$REFVAL\",\"__nope\":1}")" + check "unknown field rejected" 'unknown_field' "$R" + check "rejection is machine-readable" '"code"' "$R" + check "all violations listed" '"violations"' "$R" + + DATEF="$(echo "$DESC" | python3 -c " +import sys,json +f=json.load(sys.stdin)['entities']['$ENTITY']['fields'] +print(next((k for k,v in f.items() if v.get('type')=='datetime' and not v.get('readOnly')), ''))")" + if [ -n "$DATEF" ]; then + R="$(body -X POST "$BASE/api/collections/$ENTITY/records" "${W[@]}" -d "{\"$LABELF\":\"selfcheck\",\"$REFFIELD\":\"$REFVAL\",\"$DATEF\":\"tomorrow\"}")" + check "relative date rejected server-side" 'invalid_date' "$R" + fi + + head "3. Legitimate writes still work" + R="$(body -X POST "$BASE/api/collections/$ENTITY/records" "${W[@]}" -d "{\"$LABELF\":\"selfcheck-ok\",\"$REFFIELD\":\"$REFVAL\"}")" + ID="$(echo "$R" | json 'id')" + [ -n "$ID" ] && { ok "plain write succeeds"; CREATED+=("$ID"); } || bad "plain write succeeds" "an id" "${R:0:120}" + + head "4. Idempotency" + K="selfcheck-$RANDOM$RANDOM" + R1="$(body -X POST "$BASE/api/collections/$ENTITY/records" "${W[@]}" -H "Idempotency-Key: $K" -d "{\"$LABELF\":\"selfcheck-idem\",\"$REFFIELD\":\"$REFVAL\"}")" + I1="$(echo "$R1" | json 'id')"; [ -n "$I1" ] && CREATED+=("$I1") + R2="$(body -X POST "$BASE/api/collections/$ENTITY/records" "${W[@]}" -H "Idempotency-Key: $K" -d "{\"$LABELF\":\"selfcheck-idem\",\"$REFFIELD\":\"$REFVAL\"}")" + check "replay suppressed" 'duplicate_request' "$R2" + check "replay names the original" "$I1" "$R2" + + head "5. Origin guard (the drive-by)" + H="$(curl -s -m 8 -i "$BASE/api/collections/$ENTITY/records?perPage=1" -H 'Origin: https://evil.example' | tr -d '\r')" + case "$H" in *"Access-Control-Allow-Origin"*) bad "foreign origin gets no CORS grant" "no ACAO header" "ACAO present";; *) ok "foreign origin gets no CORS grant";; esac + check "foreign origin cannot write" 'forbidden origin' \ + "$(body -X POST "$BASE/api/collections/$ENTITY/records" -H 'Origin: https://evil.example' -H 'Content-Type: application/json' -d '{}')" + case "$H" in *"frame-ancestors"*) ok "framing restricted by CSP";; *) bad "framing restricted by CSP" "frame-ancestors" "absent";; esac + + head "6. Agent token" + if [ -n "$TOKEN" ]; then + check "write without token refused" 'agent token required' \ + "$(body -X POST "$BASE/api/collections/$ENTITY/records" -H 'Content-Type: application/json' -d "{\"$LABELF\":\"nope\"}")" + ok "token file present (mode $(stat -f '%OLp' "$PROJ/.agent-token" 2>/dev/null || echo '?'))" + else + echo " (no .agent-token — app not launched since C4; skipping)" + fi + + head "7. CLI parity" + check "schema via describe" "$ENTITY" "$(node "$CLI" data "$PROJ" schema 2>&1)" + OUT="$(node "$CLI" data "$PROJ" __nosuch create --x 1 2>&1)" + check "unknown collection named" 'No collection' "$OUT" +fi + +head "8. Attribution log" +if [ -f "$PROJ/logs/agent-actions.jsonl" ]; then + ok "agent-actions.jsonl exists ($(wc -l < "$PROJ/logs/agent-actions.jsonl" | tr -d ' ') entries)" +else + bad "agent-actions.jsonl exists" "a log file" "missing" +fi + +# ---- cleanup ------------------------------------------------------------- +for id in "${CREATED[@]:-}"; do + [ -n "$id" ] && curl -s -m 8 -X DELETE "$BASE/api/collections/$ENTITY/records/$id" "${W[@]}" -o /dev/null +done +[ ${#CREATED[@]} -gt 0 ] && printf '\n cleaned up %d test record(s)\n' "${#CREATED[@]}" + +printf '\n\033[1m%d passed, %d failed\033[0m\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/living-ui-v2/spec/OVERVIEW.md b/living-ui-v2/spec/OVERVIEW.md new file mode 100644 index 00000000..95608819 --- /dev/null +++ b/living-ui-v2/spec/OVERVIEW.md @@ -0,0 +1,863 @@ +# Living UI + A2APP — System Overview + +**The only document you need.** Why the system exists, what it is, how to +program against it, how each kind of app flows through it, and how the parts +that are not built yet attach to the parts that are. + +Status: adapter **1.6.0**. §11 states exactly what is built and what is not. + +Contents — §1 Why · §2 What · §3 How it works · §4 **The interface** (program +against this) · §5 App pipelines · §6 Any agent, and two-way · §7 Phase 4 · +§8 **Deployment and per-agent identity** · §9 Where knowledge lives · +§10 **Constraints and rejected designs** (read before changing anything) · +§11 Built vs planned · §12 Where to look + +--- + +## 1. Why + +A **Living UI** is a real application — database, API, web UI — that an AI agent +builds for a user and can then operate on their behalf. "Add a todo for tomorrow" +should become a row in a database. + +The system exists because that turns out to be much harder than it looks, in a +specific and repeatable way. From the incident that started this work: + +> A user asked: *"add a todo for me to eat chicken tomorrow"*. +> The agent guessed the collection was `items` (404), invented a CLI flag, +> guessed `tasks` (404), read the database migrations to find the real name, +> looked up a record id, and finally wrote the card with `due_date: "tomorrow"`. +> **PocketBase returned HTTP 200 and stored an empty string.** The agent read +> `"due_date":""` in its own tool output and told the user it was +> *"scheduled for tomorrow"*. It repeated the identical mistake 66 seconds later. + +Three failures, and only one of them is the model's fault: + +| failure | whose | +|---|---| +| The agent had to **guess** what the app contains | the system's — nothing told it | +| The database **accepted a bad value and reported success** | the system's — a silent 200 | +| The agent **claimed something untrue** | the model's — but nothing stopped it | + +The design principle that follows, and which the rest of this document is an +application of: + +> **A property that matters must be enforced by the system, not requested of the +> model.** Advisory text was ignored twice — "Read LIVING_UI.md" and `lui ops` +> were both in the prompt, both unused. What worked was making the wrong thing +> impossible, and making errors carry the answer. + +--- + +## 2. What + +**A2APP** is the surface a Living UI presents to any agent. Three ideas: + +```mermaid +flowchart LR + subgraph A2APP["A2APP — what an app offers an agent"] + D["Describe
the app publishes its own
data model and rules"] + G["Guard
the app refuses writes
it cannot store correctly"] + R["Receipt
the system reports what
changed, not the model"] + end + D --> G --> R +``` + +- **Describe** — the app publishes its entities, field types, enum values and + operating conventions at a URL. No guessing, no reading migrations. +- **Guard** — the app validates every write against its own schema and refuses + what it cannot store. There is no silent 200. +- **Receipt** — what the user is told is generated from the stored record, so + the model cannot misreport it. + +Each answers one of the three failures above, in the same order. + +--- + +## 3. How it works + +### 3.1 Layers + +The important structural choice is **where each piece of knowledge lives**, +because that decides who benefits from it. + +```mermaid +flowchart TB + subgraph clients["Clients — any of these"] + CB["CraftBot
via the lui CLI
a client, not a
privileged path"] + EX["External agent
Claude Desktop, Cursor
a script"] + UI["The app's own web UI
a browser"] + end + + subgraph app["The Living UI app — one process, one port"] + MW["Origin guard + agent token
who may write"] + WG["Write guard
is this value storable?"] + DS["describe + identity
what is here, how to drive it"] + DB[("PocketBase
collections")] + end + + CB --> MW + EX --> MW + UI --> MW + MW --> WG --> DB + CB -.reads.-> DS + EX -.reads.-> DS +``` + +**Everything that every client needs is inside the app.** The schema, the type +rules, the operating conventions, the guard. An external agent that has never +heard of CraftBot gets the same protection and the same information. + +**Everything CraftBot-specific stays outside**: the `lui` CLI, CraftBot's +actions, its skills. Those are one client's tooling, not the contract. + +#### Is the CLI required? No — and any client may use it + +`lui` is a Node CLI in the CraftBot repo. Nothing gates it: an external agent +with shell access on a machine that has CraftBot installed can call it exactly +as CraftBot does. It usually will not, because Claude Desktop has no shell, a +Python script will not spawn Node to make an HTTP call, and an app running +without CraftBot has no CLI at all — a Living UI needs only the PocketBase +binary at runtime. + +**It does not matter, because the CLI adds convenience, not capability.** With +`.superuser` deleted *and* the CLI removed from the loop, an app can be driven +end to end with `curl` and one token — discover, describe, resolve a label, +write, be rejected on a bad date, attribution logged. That test is what makes +the surface real rather than a description of CraftBot's private path. + +What the CLI does is client-side work any client can do for itself: + +| the CLI does | an agent without it | +|---|---| +| `"tomorrow"` → ISO date | resolves it itself — it has a real clock, which is *why* this is client-side (§10.1) | +| `"To Do"` → record id | one filtered `GET`, exactly as `describe.conventions` documents | +| schema discovery | reads `describe` — the same endpoint the CLI reads | +| readable errors | reads `code` from the JSON | + +Giving non-CraftBot agents that same convenience without a shell or Node is +precisely what the parked MCP gateway (§11) is for — which is why it, and not +the protocol, is what makes "any agent" practical rather than merely possible. + +> This split is load-bearing. Knowledge kept in a CraftBot skill is knowledge +> Claude Desktop will never have — so the rules for driving an app well +> (*prefer a declared operation*, *confirm destructive ones*, *say so if the app +> can't express it*) live in `describe.conventions`, not in a skill. + +### 3.2 The write path + +A write passes through four checks. They are ordered so that each one catches +what the one before it cannot see. + +```mermaid +flowchart TB + S["Agent wants:
add a todo, due tomorrow"] + C1["1. Client coercion — CLI
'tomorrow' → 2026-07-31
'To Do' → slvu8mulan2z667"] + C2["2. Guard: middleware
runs BEFORE coercion
bad dates, unknown fields
wrong types, bad enums"] + C3["3. PocketBase
required fields, relation ids"] + C4["4. Guard: read-back
did every value land?"] + OK[("Stored")] + RCPT["Receipt
generated from the record"] + ERR["Rejected
machine code + every violation"] + + S --> C1 --> C2 --> C3 --> OK --> C4 --> RCPT + C1 -.->|unresolvable| ERR + C2 -.->|invalid| ERR + C3 -.->|invalid| ERR + C4 -.->|value missing| ERR +``` + +Why each layer exists, and why it is where it is: + +| layer | catches | why not elsewhere | +|---|---|---| +| **1. Client coercion** | human input: `"tomorrow"`, `"To Do"` | needs a real clock, timezone database and `Intl`. The app's JS runtime has none of them, so resolving dates server-side would be wrong in ways nothing could detect | +| **2. Middleware guard** | bad dates, unknown fields, wrong types, bad enums | **must** run before PocketBase coerces the body — by the time a record hook sees it, `"tomorrow"` and `""` are indistinguishable, and a bad number is already `0` | +| **3. PocketBase** | missing required fields, bad relation ids | already correct; not re-implemented | +| **4. Read-back** | anything requested that did not land | the backstop for whatever the first three did not anticipate | + +### 3.3 What the user is told + +The model does not report what happened. + +```mermaid +sequenceDiagram + participant U as User + participant A as Agent + participant App as Living UI + participant Sys as CraftBot + + U->>A: add a todo, eat chicken tomorrow + A->>App: write (coerced values) + App-->>A: stored record + Sys->>Sys: build sentence FROM the record + Sys->>U: Added "Eat chicken" to To Do — due Fri 31 Jul + Note over A,U: the agent adds nothing when
the receipt already says it +``` + +The line the user reads is generated from what was stored. If the agent *does* +speak and claims a change when nothing was written, the message is **withheld** +and the discrepancy is handed back to it to correct — the user never sees a +system component contradicting their assistant. + +--- + +## 4. The interface + +Everything an agent needs to program against a Living UI. Four calls. + +```http +GET /api/_a2app who is this app +GET /api/_a2app/describe its data model and verbs +GET /api/collections/{entity}/records?filter=… read +POST /api/collections/{entity}/records write (needs a token) +``` + +### 4.1 Identity + +```json +{ "a2app": true, "protocol": "1.0", "adapterVersion": "1.6.0", + "app": { "id": "cde3d7c6", "name": "Kanban Board", "pbVersion": "0.39.7" }, + "schemaVersion": "sv_c28c7d1f", + "serverNow": "2026-07-30 09:15:00Z", "serverTzOffsetMinutes": 60 } +``` + +Unauthenticated. The name undersells it: this is less *"who are you"* than +**"am I talking to the right thing, and can I trust what I already know about +it?"** + +The problem it solves is specific. PocketBase serves its SPA with **HTTP 200 for +any unknown path** — `GET /api/totally-made-up` returns 200 and an HTML page — +so you cannot probe for a Living UI by looking for a 404. Ports are allocated +across 3100–3199, so an agent told *"your app is on 3100"* might reach the right +app, a **different** Living UI whose port shifted, an unrelated dev server, or a +stale process from a previous run. Every one of those answers 200. + +| field | the failure it prevents | +|---|---| +| `"a2app": true` | **The marker.** Check this, never a status code. Absent, or HTML came back → not a Living UI. It is the only reliable probe. | +| `app.id` | **Which** app. Identity that survives a port change — confirm it matches the app the user meant, rather than trusting that *something* answered. | +| `protocol` | Which **contract** this speaks: whether you know how to read the rest of the response. | +| `adapterVersion` | Which **implementation** is installed. Deliberately separate from `protocol` — the contract stays stable while the implementation gains fixes. Tells a client whether a known bug is present, and an operator which apps are stale. | +| `pbVersion` | The filter grammar is PocketBase's, and therefore part of this contract (§4.4). This says which dialect you get. | +| `schemaVersion` | Fingerprint of the data model. **Cache `describe` against it.** Without it an agent keeps writing against a schema someone has since changed — silently, because the field it remembers may no longer exist. | +| `serverNow` · `serverTzOffsetMinutes` | The app's clock and zone. Dates are resolved **client-side** (§10.1 — the app's runtime has no `Intl`), so a client needs to know whether its clock agrees. An agent in another timezone, or on a skewed machine, would otherwise disagree with the app about what "tomorrow" means without either side noticing. | + +What a client does with it: + +```text +1. GET /api/_a2app +2. no "a2app": true? → this is not a Living UI. Stop. +3. app.id not what I expect? → wrong app. Stop. +4. adapterVersion too old? → degrade, or ask the user to relaunch it +5. schemaVersion changed? → re-fetch describe before writing +6. serverNow far from mine? → my "tomorrow" is not the app's tomorrow +``` + +Steps 2 and 3 matter most in practice. Without them, an agent writing to +"the app on 3100" is trusting a port number — the least stable thing in the +system. + +### 4.2 Describe + +```json +{ "entities": { + "cards": { + "label": "title", + "records": "/api/collections/cards/records", + "fields": { + "title": { "type": "string", "required": true, "max": 255 }, + "list": { "type": "ref", "required": true, "entity": "lists" }, + "due_date": { "type": "datetime" }, + "priority": { "type": "enum", "values": ["none","low","medium","high","urgent"] }, + "created": { "type": "datetime", "readOnly": true } } } }, + "operations": [ … ], + "conventions": { … } } +``` + +Generated from the **live** schema on every request, so it cannot drift from +what the app is. `label` is the field a human uses to name a record — use it to +resolve `"To Do"` to an id. `readOnly` fields are server-managed. Write-only +fields (passwords) are never advertised. + +**Type vocabulary** — closed, and deliberately not PocketBase's, so a client +written against this works unchanged against any other backend: + +| type | wire form | +|---|---| +| `string` | JSON string | +| `number` | JSON number | +| `boolean` | JSON boolean | +| `datetime` | `"2026-07-30"` or `"2026-07-30 00:00:00.000Z"` | +| `enum` | one of `values` | +| `ref` | a 15-char record id of `entity` | +| `list` / `list` | array of the above | +| `json` · `binary` | any JSON value · file upload (multipart) | + +A `string` carrying `"format": "YYYY-MM-DD"` is a **day key** — a date stored as +text to avoid timezone drift. It is validated as a date despite its type. + +`conventions` carries the rules for driving the app *well*, not just legally: +prefer a declared operation over raw collection writes; confirm anything marked +`destructive`; write only what the app's own UI would; and if the app cannot +express what was asked, **say so rather than approximating it into a field that +means something else**. + +### 4.3 Authentication + +Two **independent** checks, not a menu — depending on who you are and what you +touch, you may need neither, either, or both. + +#### Check 1 — may you write at all? + +The discriminator is the `Origin` header, and the reason is mechanical: **a +browser always attaches `Origin` to a write; a program never does.** So "no +`Origin`" is a reliable proxy for "this is a program", which lets the rule be +*programs carry a credential* without the app's own web UI needing one. + +| where the write comes from | `Origin` | needs | +|---|---|---| +| the app's own web UI | its own loopback origin | **nothing** | +| CraftBot, an agent, `curl` | none sent | **`X-LUI-Token`** | +| any other website the user is visiting | a foreign origin | **refused — 403** | + +That third row is the attack this closes: without it, any page you happened to +have open could read and write every Living UI on your machine. + +#### Check 2 — who are you acting as? + +Only applies to `authMode: multi-user` apps. There, operations run as a +principal, so `/api/ops/*` additionally requires a **PocketBase auth token** — +the same one the app's own users sign in with. + +So a script hitting an operation on a multi-user app carries **both**: +`X-LUI-Token` (it is a program) and a PocketBase token (it is acting as +someone). On a single-user `authMode: none` app, check 2 does not exist. + +#### Where the token lives, and what it is not + +`.agent-token` in the project directory, mode 0600, created at launch. + +**It is a credential you can hand out, not a sandbox.** 0600 does not stop +another process running as the same user from reading it — and that is the +correct model for a loopback app, the same one Home Assistant and Obsidian's +local API use. What it buys is a deliberate act: giving an outside agent write +access means giving it something, rather than it being ambient. + +#### Not authentication: `X-LUI-Agent` + +Send `X-LUI-Agent: ` on writes. It is **self-asserted and +unverified** — anyone can claim any name, so it defends against nothing. + +It exists for a different problem. Every change is recorded against it in +`logs/agent-actions.jsonl`, so when several agents share an app you can tell +which one wrote what. Useless against malice; exactly right against confusion, +which is the failure that actually happens. + +### 4.4 Reading + +`GET {entity.records}` with PocketBase's `filter`, `sort`, `page`, `perPage`, +`expand`. + +> **The filter grammar is PocketBase's, which makes it part of this contract.** +> Its operators and escaping are pinned with the PocketBase version in +> [`pocketbase.version`](pocketbase.version). Treat that pin as load-bearing for +> the protocol, not just for builds: an upgrade that changes filter semantics is +> a breaking change here. + +### 4.5 Writing + +```http +POST {entity.records} PATCH {entity.records}/{id} DELETE …/{id} +``` + +**Dates** — send ISO 8601. Relative words are **rejected**: `"tomorrow"` is a +400, not a date. Resolve them client-side, where a real clock and timezone +database exist. + +**References** — send a record id. To resolve a label: +`GET /api/collections/lists/records?filter=(title="To Do")`. +**More than one match is ambiguous, not a choice** — across the shipped apps no +relation field has a unique index on its label, so multi-match is the normal +case. Ask, or fail; never pick. + +**Retries** — send `Idempotency-Key` on a write you might repeat. A replay +returns **409** naming the record the first attempt created. Reject-duplicate, +not replay-the-response; scoped per entity, 24h, in memory. + +**Quote your values.** An unquoted `#` starts a shell comment and silently +removes the rest of the command — this stored `"true"` in a colour field in +testing. + +### 4.6 Errors + +Branch on `code`, never on prose. + +```json +{ "a2app": true, "ok": false, + "code": "invalid_date", "field": "due_date", + "expected": "an ISO 8601 date", "got": "tomorrow", + "message": "Rejected by a2app (invalid_date): field \"due_date\" expects …", + "violations": [ { "code": "invalid_date", "field": "due_date", … }, + { "code": "unknown_field", "field": "bogus", … } ] } +``` + +`violations` lists **every** problem, so one round trip fixes them all. The +`a2app: true` marker distinguishes these from PocketBase's own rejections. + +| code | meaning | +|---|---| +| `unknown_field` | not a field of this entity (message lists the valid ones) | +| `read_only_field` | server-managed | +| `invalid_date` · `invalid_daykey` | not ISO 8601 · not `YYYY-MM-DD` | +| `invalid_string` · `invalid_number` · `invalid_boolean` · `invalid_enum` | wrong type | +| `not_stored` | requested, accepted, but absent from storage — failed, not partial | +| `duplicate_request` | this `Idempotency-Key` already produced a record (409) | + +Plus, at the transport layer: `403 forbidden origin`, `401 agent token +required`, `401 authentication required`. + +**Every one of these was once a silent HTTP 200.** + +--- + +## 5. App pipelines + +An app can enter the system four ways, and be used two ways. Every path +converges on the same operating surface. + +```mermaid +flowchart TB + subgraph arrive["How an app arrives"] + CR["Create
agent builds it"] + MI["Marketplace install
downloaded"] + IZ["Import
a .zip"] + end + + SYNC["adapter-sync
system hooks + version stamp"] + LAUNCH["Launch
superuser · agent token
adapter-sync · serve"] + RUN(["Running app
speaking A2APP"]) + + CR --> SYNC + MI --> SYNC + IZ --> SYNC + SYNC --> LAUNCH --> RUN + + RUN --> OP["Operate — data
no skill, no rebuild"] + RUN --> MOD["Modify — code
agent loads a build skill,
then gate + verify"] + MOD --> LAUNCH +``` + +**Every path is stamped on the way through.** `adapter-sync` runs at create, +install, import **and every launch** — the last one matters most, because it is +the only path that reaches an app a user already had. Without it the whole +system would apply solely to apps installed after it shipped. + +### Operate vs modify + +The agent decides which it is, per request — nothing is routed in advance: + +| the user says | the agent does | +|---|---| +| "add a todo for tomorrow" | one write. No skill, no rebuild, no browser | +| "mark it complete" | one write | +| "add a button that exports to markdown" | loads a build skill, edits code, runs the gate, verifies | + +Getting this wrong is expensive: a data write that triggers the build sequence +rebuilds and relaunches a live app and drives a headless browser over the user's +real records. It used to, because a build skill was preloaded into every +session. Now skills load **per run**, chosen by the agent from the request. + +--- + +## 6. Any agent, and two-way + +### 6.1 Today — agent drives app + +An agent needs four calls and one credential. No CraftBot, no CLI, no Node. + +```mermaid +sequenceDiagram + participant Ag as Any agent + participant App as Living UI + + Ag->>App: GET /api/_a2app + App-->>Ag: identity, versions, server clock + Ag->>App: GET /api/_a2app/describe + App-->>Ag: entities, types, operations, conventions + Ag->>App: GET /api/collections/lists/records?filter=(title="To Do") + App-->>Ag: the record id + Ag->>App: POST /api/collections/cards/records + X-LUI-Token + App-->>Ag: stored record — or a rejection with a machine code +``` + +This is verified: with `.superuser` deleted and the CLI removed from the loop, +an app can be driven end to end with `curl` and one token. **Anything CraftBot +can do, an external agent can do**, which is what makes the surface real rather +than a description of CraftBot's private path. + +### 6.2 Later — app drives agent (Phase 5) + +Two-way means a button in the app can ask *an* agent to do something. The +mechanism is deliberately boring: **a queue collection**, because any agent can +poll REST and almost none can receive a webhook or hold a socket open. + +```mermaid +sequenceDiagram + participant UI as App UI + participant App as Living UI + participant Ag as Any linked agent + + UI->>App: user clicks "Summarise this board" + App->>App: insert into _agent_requests + Note over App,Ag: PocketBase realtime for fast agents,
plain polling for simple ones + Ag->>App: claim the request + Ag->>Ag: do the work + Ag->>App: write _agent_results + App->>UI: result appears +``` + +**The security shape is the point, not the transport.** An app that can ask an +agent to act, combined with third-party apps, means an app someone else wrote +could drive the user's agent — which holds their email, calendar and payment +integrations. So Phase 5 requires, from day one: + +- a **declared capability vocabulary** in the manifest — exactly which agent + actions this app may request +- **consent at install**, per capability, in words the user can evaluate +- **no arbitrary passthrough**, ever + +That last rule is not theoretical: the existing CraftBot integration bridge had +exactly that shape — caller-controlled destination with the user's real +credentials attached — and it is now gated by an allowlist plus a per-project +capability check. + +--- + +## 7. Phase 4 — any technology + +**What it buys you:** point CraftBot at an app written in *anything* — Django, +Rails, Next.js, a SaaS with a REST API — and it becomes a Living UI. From that +moment every agent drives it exactly as it drives a PocketBase one, and cannot +tell the difference. + +Today A2APP is a PocketBase hook, and a Django app cannot run one. Phase 4 adds +a **fourth arrival path** to the pipeline in §5. + +```mermaid +flowchart TB + subgraph pb["Today — a PocketBase Living UI"] + PBA["PocketBase app"] --> PBH["A2APP hook
inside the app"] + end + + subgraph p4["Phase 4 — bringing in any app"] + SRC["Your existing app
Django · Rails
Next.js · a SaaS API"] + PROBE["1. CraftBot probes it
endpoints, schema
OpenAPI"] + MAP["2. Mapping — DATA
ships with the app
entities · fields
in protocol types
reviewable"] + VER["3. CraftBot verifies
real reads and writes
if it does not work
it is rejected"] + SRC --> PROBE --> MAP --> VER + end + + RT["Runtime — CODE
ONE shared copy
reads a mapping
serves the surface"] + SURF(["The same A2APP surface
describe · guard
errors · receipts"]) + AG["Any agent
cannot tell which is which"] + + PBH --> SURF + VER --> RT --> SURF + SURF --> AG +``` + +### What actually happens + +1. **You point CraftBot at the app** — import it, or have CraftBot build it in + whatever stack you asked for. +2. **CraftBot probes it** and writes a **mapping**: which entities exist, which + fields they have *in protocol types* (`datetime`, `ref`, `enum` — not + Django's or Rails' types), and how to reach them. +3. **CraftBot verifies the mapping** by performing real reads and writes. A + mapping that does not actually work is rejected rather than shipped — this is + the step that stops a plausible-looking but wrong mapping getting published. +4. **The shared runtime** reads that mapping and serves `describe`, the guard, + the error contract and receipts — the same surface §4 documents. + +After step 4 the app is a Living UI in every way that matters to an agent. + +### Why this shape, and not a hook per technology + +- **The runtime is code, and shared.** A date-parsing bug is one fix, not a + migration across every app ever installed. The opposite arrangement — logic + shipped inside each app — is what killed the first architecture (§10.2). +- **The mapping is data, not code.** That is what makes it safe to publish + someone else's adapter: a mapping can be read and validated, where code would + have to be trusted. Some APIs will need a code escape hatch; those are exactly + the ones a human should review. +- **Clients need no change.** The CLI and every external agent already speak + protocol types rather than PocketBase's, precisely so a new backend costs + nothing on the client side. + +### What stays in-app for PocketBase + +Write verification and identity remain a PocketBase hook even after Phase 4, +because they must protect callers that never go through any runtime — someone +hitting `/api/collections/...` directly still gets the guard. + +### Honest limits + +Not every API will map declaratively. The pipeline needs an explicit *"I cannot +map this"* outcome rather than emitting a mapping that looks right and is subtly +wrong — which is why step 3 exists and must not be skipped. + +## 8. Deployment and per-agent identity + +Everything so far assumes the app is on **loopback**, on the user's own machine. +That assumption is doing real work, and the moment an app is deployed somewhere +reachable it stops holding. + +### 8.1 What breaks when you deploy + +| today's mechanism | why it works on loopback | what happens deployed | +|---|---|---| +| **Origin guard** — foreign origins refused | the only legitimate browser is the app's own UI | there are now legitimate remote origins; "loopback" stops being the test | +| **Agent token** — a 0600 file | any agent that can read the file is already running as the user | a remote agent cannot read a local file, and one shared token cannot distinguish two agents | +| **`X-LUI-Agent`** — self-asserted | fine: everything local is already trusted | anyone can claim any name — worthless once callers are untrusted | +| **All-or-nothing access** | a local agent already has full machine access | a remote agent should get *some* access, not all of it | + +The shared token is the crux: it answers *"is this a program?"*, not +*"which program, acting for whom, allowed to do what?"* Deployment needs the +second question answered. + +### 8.2 What replaces each + +```mermaid +flowchart TB + subgraph now["Loopback today"] + T1["Origin is loopback"] + T2["Shared .agent-token"] + T3["X-LUI-Agent
self-asserted"] + T4["Full access"] + end + subgraph dep["Deployed"] + D1["TLS + allowed origins"] + D2["Per-agent
capability token"] + D3["Agent keypair
verified"] + D4["Scopes from describe
+ user consent"] + end + T1 --> D1 + T2 --> D2 + T3 --> D3 + T4 --> D4 +``` + +**Agent identity becomes a keypair.** An agent generates its own; the public +half *is* its identity. There is no registry to join — identity is free, and +**trust** is granted per app by its owner. + +**Access becomes a grant.** A grant binds three things: *which agent key*, +*which user it acts for*, and *which scopes*. Effective permission is the user's +own permission **intersected** with the granted scopes, so an agent can never +exceed the person it acts for. + +**Scopes are derived, not invented.** They come straight out of `describe`: +`op:` for each declared operation, `data::read|write` for each +entity. There is no second permission vocabulary to keep in step — the thing an +agent reads to learn the app is the same thing the consent screen is built from. + +### 8.3 The flow + +```mermaid +sequenceDiagram + participant Ag as Agent + participant App as Deployed Living UI + participant U as Owner + + Ag->>App: request access
public key · scopes · reason + App->>U: consent screen
who · what · as whom + U-->>App: approve → a Grant is stored + Ag->>App: exchange for a token
(proves it holds the private key) + App-->>Ag: short-lived capability token + Ag->>App: writes, carrying the token + Note over App: every call checked against
scopes ∩ the user's own permission + Note over App: every call audited; owner can revoke,
which kills refresh within one TTL +``` + +Consent is **human** — no agent approves another agent's access. Anything marked +`destructive` can additionally require confirmation *per call* rather than +riding on the standing grant. + +### 8.4 Two-way, with identity + +Deployment changes the app→agent direction too. Today the request queue is open: +any linked agent may claim any request. Deployed, a queued request must name +**which grant** may claim it, and the claimer must present its token. + +| direction | loopback today | deployed | +|---|---|---| +| agent → app | shared token | capability token, scope-checked per call | +| app → agent | any agent polls the queue | request names an eligible grant; claimer authenticates | + +And the reason this matters more than it may seem: an app that can ask an agent +to act, combined with apps written by third parties, means **someone else's app +could drive your agent** — which holds your mail, calendar and payment +integrations. So the capability declaration and install-time consent described +in §6.2 are not optional extras; they are what makes the direction shippable at +all. + +### 8.5 Why today's design already accepts this + +None of the above requires unpicking what exists. The current pieces are the +degenerate single-agent case of the same shapes: + +| exists now | becomes | +|---|---| +| `describe` publishes entities and operations | **the scope vocabulary**, unchanged | +| the guard checks every write | the place a **scope check** slots in beside it | +| `X-LUI-Agent` names the caller | the field a **verified key** fills | +| one shared `.agent-token` | one grant, for one agent, with every scope | +| `agent-actions.jsonl` | the **audit trail** consent and revocation need | + +That is the payoff of keeping protocol knowledge in the app rather than in one +client's tooling (§3.1): the thing an agent reads to learn an app is the same +thing its owner reads to decide what to allow. + +### 8.6 The mechanics, settled + +The decisions behind §8.2–8.4, and why each went the way it did: + +| decision | choice | why | +|---|---|---| +| **Where authority lives** | in the app | the app is the authority over its own access. Hosting adds TLS, rate limits, backups — never the capability itself, so a self-hosted app is not second-class | +| **Agent identity** | self-generated keypair | no central registry to join. Identity is free; *trust* is what an owner grants, per app | +| **Token format** | the app's own auth records | the verifier is always the issuer — a token for app X is only ever presented to app X — so signed offline-verifiable tokens buy nothing, and deleting the record *is* instant revocation. No blocklists, no crypto surface | +| **Token lifetime** | short, refreshed by proof-of-possession | refresh means signing a server challenge with the private key, so a leaked bearer token dies at expiry and cannot be renewed | +| **Scope granularity** | coarse: `op:` and `data::read\|write` | a permission an owner cannot evaluate in seconds gets approved blindly. Record-level safety comes free from the intersection in §8.2, not from finer scopes | +| **Who consents** | a human, always | no agent approves another agent. The grant record reserves a field for delegated policy, so that could change later without a migration | +| **Destructive operations** | may demand consent per call | a standing grant should not silently cover "delete everything" | +| **Trust tiers** | any agent key + consent (default); optionally only attested keys | the strict tier filters anonymous spray, but consent is still required either way, and the default works with no external service | + +Grants and agent keys live in system-managed collections inside the app — +invisible to app code, surviving a kit sync. Every token-authenticated call is +logged with the agent's fingerprint, the principal, the scope used and the +outcome; the owner sees that list and can revoke any grant in one click, which +cuts off refresh immediately and ends access within one token lifetime. + +**What this defends against.** A stolen token dies at expiry and cannot be +refreshed without the private key. A rogue agent that *has* consent is still +bounded by scopes ∩ its principal, audited, and revocable. Exceeding the user it +acts for is impossible by construction rather than by policy. And because +authority sits in the app, compromising the hosting platform does not hand over +the apps. + +**Status: designed, not built.** Deliberately deferred until apps are actually +deployed — on loopback it would add ceremony without adding safety, and the +degenerate single-agent case (§8.5) already covers that setting. + +--- + +## 9. Where knowledge lives + +The single most useful table in this document. When adding something, ask which +row it belongs to. + +| knowledge | lives in | why | +|---|---|---| +| entities, field types, enum values | **the app** (`describe`) | every client needs it; generated live so it cannot drift | +| how to drive the app well | **the app** (`describe.conventions`) | an external agent has no access to CraftBot's skills | +| what is a valid value | **the app** (guard) | the last line every caller passes | +| turning `"tomorrow"` into a date | **the client** | needs a clock, timezone data, `Intl` — the app's runtime has none | +| the `lui` CLI, CraftBot actions | **CraftBot** | one client's tooling | +| how to build/modify an app | **a skill, loaded per run** | only relevant to the run that is doing it | + +--- + +## 10. Constraints and rejected designs + +Read this before changing anything. Each item was measured, and several +architectures died on them. + +### 10.1 Verified constraints + +| constraint | consequence | +|---|---| +| **PocketBase coerces the request body *before* record hooks run.** `{"due_date":"tomorrow"}` reaches a record hook as an empty DateTime — **identical** to `{"due_date":""}`. A bad number is already `0`. | Validation must be **router middleware**. A record hook cannot tell "clear this field" from "I sent garbage", and cannot see bad numbers or booleans at all. | +| **The JS runtime has no `Intl`**, and `toLocaleDateString` silently ignores both `timeZone` and locale, using the server's zone. | Date *resolution* cannot happen in the app. The client resolves; the app only validates. Rejecting `"tomorrow"` needs no clock — converting it does. | +| **`runInTransaction` with the outer handle deadlocks the process permanently**, and `/api/health` keeps returning 200 throughout. | One mistake in one hook turns an app into a read-only zombie that passes its own health check. The build gate rejects `e.app`/`$app` inside a transaction callback. | +| **Hook callbacks run in isolated VMs that cannot see their own file's scope.** | Every callback must reach shared code through `require()`. A file-scope helper fails with `ReferenceError`. This trap was hit *after* being documented. | +| **PocketBase capitalises error messages** and recursively normalises the `data` map of any thrown error. | A machine-readable code cannot survive the throw path — the guard **returns** a response from middleware instead. A case-sensitive prefix match on a message silently fails. | +| **`f.type` is a method, not a property.** | Reading it as a property yields a Go func; `e.json()` then emits a zero-length HTTP 200 with the error only in the server log. | +| **No relation field in any shipped app has a unique index on its label.** A board's lists are seeded `To Do / In Progress / Done` for *every* board. | Ambiguity is the normal case for label resolution, not an edge case. Never guess. | +| **Some date fields are deliberately `text`** (`tasks.due`, `entries.day` — "day key as plain text, avoids timezone drift"). | A type check cannot protect them. Writing `"tomorrow"` stores it verbatim, *looks* populated, and breaks ordering permanently (`"t"` > `"2"`). Hence the day-key convention. | + +### 10.2 Designs that were tried and rejected + +**A server-side operation runtime inside each app.** Killed by four of the +constraints above at once — no `Intl`, the transaction deadlock, `$app.save()` +bypassing API rules, and no delivery path for code shipped inside apps. + +**Auto-generated CRUD verbs per collection.** Zero of the shipped apps use the +existing `crud` executor, and renaming `cards create` to `cards.add` fixes +nothing about *values*, which was the actual failure. It would also have +inflated the surface from a handful of verbs to ~168. + +**A three-verb protocol (`describe`/`bind`/`invoke`).** It had no read verb, its +timezone claim was false across a DST boundary, its "changes" guarantee could +not be honoured, it cost **+25k tokens per write** against schema-in-context, +and a paper published in May 2026 had already shipped the same idea with six +verbs and an evaluation. + +**A general `undo`.** Most relations cascade on delete, there are no backups, +and one app sends real email. A partial undo is worse than none — it manufactures +relief exactly when the user stops checking. + +**Routing capability by session.** Skills were bound to sessions at creation, +which encodes *how the project arrived* rather than *what is being asked*. Now +they load per run. + +### 10.3 The recurring lesson + +Three separate defects were found by **writing something down** or **counting +rows**, not by testing: + +- `describe` advertised `email` as read-only, so no client would ever have + attempted a signup. +- The error `data` map was unusable, so "branch on `code`" was undeliverable. +- Idempotency keys silently never matched, because two code paths computed them + differently — every negative test passed while duplicates sailed through. + +Negative tests prove bad things fail. They do not prove the contract you are +promising is the one you built. + +--- + +## 11. Built vs planned + +| | state | +|---|---| +| Describe, identity, guard, read-back backstop | **built**, adapter 1.6.0 | +| Agent token, origin guard, ops auth, rate limits | **built** | +| Idempotency (`Idempotency-Key`) | **built** | +| System-authored receipts, false-claim gate | **built** | +| CLI as a thin client (no superuser needed) | **built**, verified by driving an app with curl alone | +| Adapter delivery at create/install/import/launch | **built** | +| Per-run skill selection | **built** | +| Collection rules still open (`''`) | **known** — the origin guard closes the browser attack; see §11, "collection rules" | +| `walk_verify` clicks UI buttons, can touch live data | **known defect** | +| Bulk writes across many turns produce many receipt lines | **known** | +| Registry + MCP gateway (one install, every agent) | **parked** | +| Any-technology mapping + runtime | **parked** (Phase 4) | +| App → agent, capabilities, consent | **parked** (Phase 5) | + +--- + +## 12. Where to look + +| what | where | +|---|---| +| the guard, describe, identity | `blueprint/pb/pb_hooks/_a2app*.js` | +| validation rules (pure, portable) | `blueprint/pb/pb_hooks/_a2app_rules.js` | +| origin guard, ops auth, rate limits | `blueprint/pb/pb_hooks/_system.pb.js` | +| client coercion, schema access | `tools/src/lib/schema.ts` | +| CLI commands | `tools/src/commands/` | +| receipts, false-claim gate | `app/agent_base.py` | +| adapter delivery, token, launch | `app/living_ui/v2_runner.py` | +| session + skill selection | `app/living_ui/manager.py` | +| a runnable check of every guarantee | `scripts/a2app-selfcheck.sh` | diff --git a/living-ui-v2/tools/src/cli.ts b/living-ui-v2/tools/src/cli.ts index 4b167679..fd1cbd88 100755 --- a/living-ui-v2/tools/src/cli.ts +++ b/living-ui-v2/tools/src/cli.ts @@ -18,6 +18,7 @@ const COMMANDS: Record = { data: { summary: 'Read/write collection records of the RUNNING app (list/get/create/update/delete)' }, probe: { summary: 'Scripted headless-browser walk of the RUNNING app (goto/click/type/read/screenshot)' }, 'kit-sync': { summary: 'Re-vendor the kit into a project (wholesale replace)' }, + 'adapter-sync': { summary: 'Re-vendor only the system pb_hooks (A2APP adapter) — no rebuild' }, }; async function main(): Promise { diff --git a/living-ui-v2/tools/src/commands/adapter-sync.ts b/living-ui-v2/tools/src/commands/adapter-sync.ts new file mode 100644 index 00000000..4a9e060e --- /dev/null +++ b/living-ui-v2/tools/src/commands/adapter-sync.ts @@ -0,0 +1,48 @@ +/** + * lui adapter-sync — re-vendor ONLY the system pb_hooks files. + * + * `kit-sync` does two jobs: it re-vendors `frontend/src/kit` AND the system + * hooks. When all you need is to push a fixed adapter — a validation bug, a + * security patch — the kit half is unwanted: it rewrites 168 KB of frontend + * source per app, which in a source repo that tracks only a `.gitkeep` there + * is pure noise, and it forces a rebuild the change did not require. + * + * This is also the delivery path A2APP-PLAN §3.4 says does not exist: a fix to + * code shipped inside apps otherwise reaches nothing already installed. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeSystemHashes } from '../lib/hashes.ts'; +import { adapterVersion, vendorSystemFilesInto } from '../lib/kit.ts'; +import { log } from '../lib/log.ts'; + +export async function run(args: string[]): Promise { + const projectDir = args[0]; + if (projectDir === undefined || !existsSync(join(projectDir, 'manifest.json'))) { + log.error('Usage: lui adapter-sync (must contain manifest.json)'); + return 1; + } + + const written = vendorSystemFilesInto(projectDir); + if (written.length === 0) { + log.error('No system hook files found in the blueprint — nothing to sync.'); + return 1; + } + + const version = adapterVersion(); + const manifestPath = join(projectDir, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { adapterVersion?: string }; + const previous = manifest.adapterVersion ?? 'none'; + manifest.adapterVersion = version; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + + // Only re-canonize if the kit is actually vendored here; otherwise the hash + // manifest would record entries for files that are not present. + if (existsSync(join(projectDir, 'frontend', 'src', 'kit', 'kit.json'))) { + writeSystemHashes(projectDir); + } + + log.ok(`Adapter ${previous} → ${version} (${written.length} file(s), no rebuild required)`); + for (const file of written) log.raw(` ${file}`); + return 0; +} diff --git a/living-ui-v2/tools/src/commands/data.ts b/living-ui-v2/tools/src/commands/data.ts index 6428ae62..eb72c2f2 100644 --- a/living-ui-v2/tools/src/commands/data.ts +++ b/living-ui-v2/tools/src/commands/data.ts @@ -14,9 +14,23 @@ */ import { log } from '../lib/log.ts'; import { loadProject, request } from '../lib/project.ts'; +import { coerceBody, describe, droppedFields, fetchSchema, suggest } from '../lib/schema.ts'; // Reserved flags that control the query/body, never treated as record fields. -const CONTROL_FLAGS = new Set(['json', 'filter', 'sort', 'limit']); +const CONTROL_FLAGS = new Set(['json', 'filter', 'sort', 'limit', 'idempotency-key']); + +function safeJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + } catch { + return null; + } +} + +function idempotencyHeaders(key: string | undefined): Record | undefined { + return key === undefined ? undefined : { 'Idempotency-Key': key }; +} function flag(args: string[], name: string): string | undefined { const i = args.indexOf(`--${name}`); @@ -33,7 +47,7 @@ function coerceScalar(v: string): unknown { } /** Collect `--field value` pairs (excluding CONTROL_FLAGS) into a body object. - * A flag with no following value (or followed by another flag) becomes true. */ + * Every flag must carry a value — a valueless one is an error, not `true`. */ function collectFields(args: string[]): Record { const out: Record = {}; for (let i = 0; i < args.length; i++) { @@ -46,11 +60,16 @@ function collectFields(args: string[]): Record { } const next = args[i + 1]; if (next === undefined || next.startsWith('--')) { - out[key] = true; // bare flag - } else { - out[key] = coerceScalar(next); - i++; + // This used to become `true`, which silently stored a boolean in whatever + // field was named — a colour field ended up holding "true". The job here + // is to STOP that write, not to explain how it happened: a valueless flag + // and a value eaten by shell quoting arrive byte-identically, so any + // cause we named would be a guess. The note teaches quoting up front; + // this just refuses. + throw new Error(`--${key} has no value. Every flag needs one: --${key} "value".`); } + out[key] = coerceScalar(next); + i++; } return out; } @@ -75,8 +94,43 @@ export async function run(args: string[]): Promise { return 1; } const project = loadProject(dirArg); + const schema = await fetchSchema(project); + + // `data schema` — the app's data model, so an agent never has to guess + // a collection name or a field type (the failure that motivated all of this). + if (collection === 'schema' && !schema.has('schema')) { + log.raw(`${project.name} — collections (field(type), * = required):\n${describe(schema)}`); + return 0; + } + + // PocketBase answers an unknown collection with a bare 404 that names + // nothing. Answer it properly instead. + if (schema.size > 0 && !schema.has(collection)) { + const hint = suggest(collection, [...schema.keys()]); + log.error( + `No collection "${collection}" in ${project.name}${hint !== null ? ` — did you mean "${hint}"?` : ''}` + ); + log.raw(`Collections (field(type), * = required):\n${describe(schema)}`); + return 1; + } + + // Opt-in, not automatic: the CLI does not retry internally, so a generated + // key would protect nothing. It exists so a caller that DOES retry — an agent + // loop, an HTTP layer — can make a write safe to repeat. + const idempotencyKey = flag(args, 'idempotency-key'); const base = `/api/collections/${collection}/records`; - const body = buildBody(args); + let body = buildBody(args) as Record | undefined; + + // Coerce CLI-side: relative dates and relation labels. The app still + // validates — this only makes a well-formed request out of human input. + if (body !== undefined && (verb === 'create' || verb === 'update')) { + const coerced = await coerceBody(project, schema, collection, body); + if (coerced.errors.length > 0) { + for (const message of coerced.errors) log.error(message); + return 1; + } + body = coerced.body; + } let res; switch (verb) { @@ -98,13 +152,13 @@ export async function run(args: string[]): Promise { case 'create': if (body === undefined) return usageError("create needs fields (e.g. --title \"…\") or --json '{...}'"); - res = await request(project, 'POST', base, body); + res = await request(project, 'POST', base, body, idempotencyHeaders(idempotencyKey)); break; case 'update': if (id === undefined) return usageError('update needs an '); if (body === undefined) return usageError("update needs fields (e.g. --status \"…\") or --json '{...}'"); - res = await request(project, 'PATCH', `${base}/${id}`, body); + res = await request(project, 'PATCH', `${base}/${id}`, body, idempotencyHeaders(idempotencyKey)); break; case 'delete': if (id === undefined) return usageError('delete needs an id'); @@ -113,8 +167,48 @@ export async function run(args: string[]): Promise { default: return usageError(`unknown verb "${verb}"`); } - log.raw(res.body || `(HTTP ${res.status}${res.status < 300 ? ', ok' : ''})`); - return res.status < 300 ? 0 : 1; + // Failure: surface the app's message plainly instead of a wall of JSON, so + // the reason reaches the caller rather than being buried in a body. + if (res.status >= 300) { + const parsed = safeJson(res.body); + log.error(String(parsed?.['message'] ?? `HTTP ${res.status}`)); + + // A2APP rejections list EVERY problem, so show them all — one round trip + // should be enough for the caller to fix everything. + const violations = parsed?.['violations']; + if (Array.isArray(violations) && violations.length > 1) { + for (const v of violations.slice(1) as { field?: string; expected?: string }[]) { + log.raw(` also: --${v.field} expects ${v.expected}`); + } + } else { + // PocketBase's own rejections keep its shape and carry no a2app marker. + const detail = parsed?.['data']; + if (detail !== undefined && Object.keys(detail as object).length > 0) { + log.raw(` fields: ${JSON.stringify(detail)}`); + } + } + return 1; + } + + log.raw(res.body || `(HTTP ${res.status}, ok)`); + + // Backstop for apps whose adapter predates the in-app write guard: if a value + // we asked for is missing from what came back, the write did not do what was + // asked, and that must not look like success. + if (body !== undefined && (verb === 'create' || verb === 'update')) { + const saved = safeJson(res.body); + if (saved !== null) { + const dropped = droppedFields(body, saved); + if (dropped.length > 0) { + log.error( + `WRITE INCOMPLETE — the app accepted the request but did not store: ${dropped.join(', ')}. ` + + `Do NOT report this as done.` + ); + return 1; + } + } + } + return 0; function usageError(msg: string): number { log.error(msg); diff --git a/living-ui-v2/tools/src/commands/kit-sync.ts b/living-ui-v2/tools/src/commands/kit-sync.ts index 74ceac6d..fcb13d0b 100644 --- a/living-ui-v2/tools/src/commands/kit-sync.ts +++ b/living-ui-v2/tools/src/commands/kit-sync.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { writeSystemHashes } from '../lib/hashes.ts'; -import { vendorKitInto } from '../lib/kit.ts'; +import { adapterVersion, vendorKitInto, vendorSystemFilesInto } from '../lib/kit.ts'; import { log } from '../lib/log.ts'; export async function run(args: string[]): Promise { @@ -17,15 +17,26 @@ export async function run(args: string[]): Promise { const version = vendorKitInto(projectDir); + // System hooks too — this is how imported and marketplace apps receive the + // A2APP write guard, and it makes the re-canonization below honest. + const systemFiles = vendorSystemFilesInto(projectDir); + const adapter = adapterVersion(); + const manifestPath = join(projectDir, 'manifest.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { kitVersion?: string }; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + kitVersion?: string; + adapterVersion?: string; + }; const previous = manifest.kitVersion ?? 'unknown'; + const previousAdapter = manifest.adapterVersion ?? 'none'; manifest.kitVersion = version; + manifest.adapterVersion = adapter; writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); // Re-canonize: the sync itself is the new legitimate system-file state. writeSystemHashes(projectDir); log.ok(`Kit ${previous} → ${version} in ${projectDir} (rebuild required)`); + log.ok(`Adapter ${previousAdapter} → ${adapter} (${systemFiles.length} system hook file(s))`); return 0; } diff --git a/living-ui-v2/tools/src/commands/validate.ts b/living-ui-v2/tools/src/commands/validate.ts index 37b9f710..16dbda61 100644 --- a/living-ui-v2/tools/src/commands/validate.ts +++ b/living-ui-v2/tools/src/commands/validate.ts @@ -143,6 +143,56 @@ function collectHookRoutes(projectDir: string): Set { return routes; } +/** + * Reject the PocketBase transaction footgun (spec A2APP-PLAN §3.2). + * + * `runInTransaction(txApp => …)` hands you a transaction handle. Using the + * OUTER app handle (`e.app` / `$app`) inside that callback does not merely + * misbehave: + * - a READ returns stale data silently + * - a WRITE **deadlocks the whole process, permanently** + * and the app keeps answering `GET /api/health` with 200 throughout, so the + * host's own health check cannot see it. One line in one hook turns the app + * into a read-only zombie. + * + * The correct handle is the callback's parameter. This is a cheap structural + * check for a failure that is otherwise near-impossible to diagnose from + * outside. + */ +function checkTransactionHandles(projectDir: string): void { + const hooksDir = join(projectDir, 'pb', 'pb_hooks'); + if (!existsSync(hooksDir)) return; + + for (const name of readdirSync(hooksDir)) { + if (!name.endsWith('.js')) continue; + const source = readFileSync(join(hooksDir, name), 'utf8'); + + // Find each runInTransaction( … ) callback body by brace matching, so a + // nested function or object literal does not end the scan early. + const opener = /runInTransaction\s*\(/g; + for (const start of source.matchAll(opener)) { + const from = (start.index ?? 0) + start[0].length; + let depth = 1; + let i = from; + for (; i < source.length && depth > 0; i++) { + if (source[i] === '(') depth++; + else if (source[i] === ')') depth--; + } + const body = source.slice(from, i); + const offender = /\b(e\.app|\$app)\s*\./.exec(body); + if (offender !== null) { + const line = source.slice(0, from + offender.index).split('\n').length; + throw new Error( + `${name}:${line}: "${offender[1]}" used inside runInTransaction — use the ` + + `callback's own transaction handle instead. Writing through the outer ` + + `handle DEADLOCKS the process permanently, and /api/health keeps ` + + `returning 200 so nothing detects it.` + ); + } + } + } +} + /** * Append the offending SOURCE to every error that names a location, in any * gate step — agents fix the wrong thing when they only see line numbers. @@ -332,6 +382,10 @@ export async function run(args: string[]): Promise { runStep(errors, 'operations.json (structure)', () => validateOps(projectDir)); + runStep(errors, 'hooks (transaction handles)', () => + checkTransactionHandles(projectDir) + ); + runStep(errors, 'ownership (system files unmodified)', () => { const drift = verifySystemHashes(projectDir); const problems: string[] = [ diff --git a/living-ui-v2/tools/src/lib/hashes.ts b/living-ui-v2/tools/src/lib/hashes.ts index 817583b3..00ff3618 100644 --- a/living-ui-v2/tools/src/lib/hashes.ts +++ b/living-ui-v2/tools/src/lib/hashes.ts @@ -9,8 +9,11 @@ import { join, relative, sep } from 'node:path'; const HASH_FILE = join('.lui', 'system-hashes.json'); -/** System-managed paths, relative to the project root (files or directories). */ -const SYSTEM_PATHS = [ +/** System-managed paths, relative to the project root (files or directories). + * Exported because this list is also the delivery manifest: `kit-sync` + * re-vendors every system file so the hashes it re-canonizes are hashes of + * files it actually just wrote (see vendorSystemFilesInto). */ +export const SYSTEM_PATHS = [ 'frontend/src/kit', 'frontend/src/main.tsx', 'frontend/src/config.gen.ts', @@ -20,6 +23,9 @@ const SYSTEM_PATHS = [ 'frontend/tsconfig.json', 'pb/pb_hooks/_system.pb.js', 'pb/pb_hooks/_craftbot_bridge.js', + 'pb/pb_hooks/_a2app.pb.js', + 'pb/pb_hooks/_a2app_lib.js', + 'pb/pb_hooks/_a2app_rules.js', 'manifest.json', ]; diff --git a/living-ui-v2/tools/src/lib/kit.ts b/living-ui-v2/tools/src/lib/kit.ts index 73e8e2fd..28dcc511 100644 --- a/living-ui-v2/tools/src/lib/kit.ts +++ b/living-ui-v2/tools/src/lib/kit.ts @@ -1,7 +1,8 @@ /** Kit vendoring — wholesale copy, never merge (spec V2/D6). */ -import { cpSync, readFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { kitDir } from './paths.ts'; +import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { SYSTEM_PATHS } from './hashes.ts'; +import { blueprintDir, kitDir } from './paths.ts'; export function kitVersion(): string { const meta = JSON.parse(readFileSync(join(kitDir(), 'kit.json'), 'utf8')) as { version: string }; @@ -20,3 +21,41 @@ export function vendorKitInto(projectDir: string): string { cpSync(join(kitDir(), 'kit.json'), join(dest, 'kit.json')); return kitVersion(); } + +/** The pb_hooks files tooling owns. Derived from SYSTEM_PATHS so the delivery + * list and the ownership list can never drift apart. */ +function systemHookFiles(): string[] { + return SYSTEM_PATHS.filter((p) => p.startsWith('pb/pb_hooks/')); +} + +/** Adapter version, read from the hook itself so there is one source of truth. */ +export function adapterVersion(): string { + const src = readFileSync(join(blueprintDir(), 'pb', 'pb_hooks', '_a2app_lib.js'), 'utf8'); + return /ADAPTER_VERSION\s*=\s*'([^']+)'/.exec(src)?.[1] ?? '0.0.0'; +} + +/** + * Re-vendor the system-managed pb_hooks files from the blueprint. + * + * Per-file rather than wholesale, because pb_hooks is otherwise AGENT-owned + * (spec A1) — `ops.pb.js` and anything the agent wrote must survive. This is + * what delivers the A2APP write guard to projects that did not come from + * `create`: both `import_project_zip` and `install_from_marketplace` already + * call kit-sync, so imported and marketplace apps pick it up on arrival. + * + * It also closes a laundering bug: kit-sync re-canonizes hashes for every + * SYSTEM_PATH, so any system file it did NOT rewrite had its drifted state + * blessed as canonical. Now every file it re-canonizes is one it just wrote. + */ +export function vendorSystemFilesInto(projectDir: string): string[] { + const written: string[] = []; + for (const rel of systemHookFiles()) { + const src = join(blueprintDir(), rel); + if (!existsSync(src)) continue; + const dest = join(projectDir, rel); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + written.push(rel); + } + return written; +} diff --git a/living-ui-v2/tools/src/lib/project.ts b/living-ui-v2/tools/src/lib/project.ts index 64ce3229..599b9d8b 100644 --- a/living-ui-v2/tools/src/lib/project.ts +++ b/living-ui-v2/tools/src/lib/project.ts @@ -46,6 +46,14 @@ export function loadOps(project: ProjectRef): Operation[] { return raw.operations ?? []; } +/** The project's agent token, or null when the project predates it. */ +export function readAgentToken(project: ProjectRef): string | null { + const file = join(project.dir, '.agent-token'); + if (!existsSync(file)) return null; + const value = readFileSync(file, 'utf8').trim(); + return value === '' ? null : value; +} + /** Superuser token via the project-local .superuser file (absent on imports). */ export async function authToken(project: ProjectRef): Promise { const credFile = join(project.dir, '.superuser'); @@ -65,10 +73,23 @@ export async function request( method: string, path: string, body?: unknown, + extraHeaders?: Record, ): Promise<{ status: number; body: string }> { - const headers: Record = { 'Content-Type': 'application/json' }; + // Attribution: the app records this against every write (spec Phase 1 B6). + // Self-asserted and worthless against malice — exactly right against + // confusion, which is the real problem when several agents share one app. + const headers: Record = { + 'Content-Type': 'application/json', + 'X-LUI-Agent': process.env['LUI_AGENT'] ?? 'lui-cli', + }; + // Agent token (spec Phase 2 C4): the credential a non-browser client presents + // to write. Absent on projects that predate it — the app then does not + // require one, so this stays backwards compatible. + const agentToken = readAgentToken(project); + if (agentToken !== null) headers['X-LUI-Token'] = agentToken; const token = await authToken(project); if (token !== null) headers['Authorization'] = token; + if (extraHeaders !== undefined) Object.assign(headers, extraHeaders); const init: RequestInit = { method, headers }; if (body !== undefined) init.body = JSON.stringify(body); const res = await fetch(`${project.baseUrl}${path}`, init); diff --git a/living-ui-v2/tools/src/lib/schema.ts b/living-ui-v2/tools/src/lib/schema.ts new file mode 100644 index 00000000..5d7e0a6b --- /dev/null +++ b/living-ui-v2/tools/src/lib/schema.ts @@ -0,0 +1,260 @@ +/** + * Client-side view of an app, read through the A2APP surface (spec Phase 2 C5). + * + * ── Why this reads /api/_a2app/describe and not /api/collections ── + * `/api/collections` is PocketBase's ADMIN endpoint: superuser only. Using it + * made this CLI a privileged client that no third-party agent could imitate, + * which is exactly the "vendor keeps the valuable half" failure the whole + * design is meant to avoid. `describe` is the public surface, it speaks + * protocol types rather than PocketBase's, and anything this CLI can do with + * it, any agent can. + * + * ── Division of labour with the in-app guard ── + * CLI — COERCES. Only side with a real clock, timezone database and `Intl`, + * and the only one that can look a label up. PocketBase's JS VM has + * none of those (spec §3.1). + * App — VALIDATES. The only layer every caller passes through. + * + * So this file makes a well-formed request; it does not re-implement the rules. + */ +import { request, type ProjectRef } from './project.ts'; + +/** A field as `describe` reports it — protocol types, not PocketBase's. */ +export interface Field { + name: string; + type: string; // string | number | boolean | datetime | enum | ref | list<…> | json | binary + required?: boolean; + readOnly?: boolean; + max?: number; + values?: string[]; + entity?: string; // target entity for ref / list + format?: string; // e.g. YYYY-MM-DD for day-key fields +} + +export interface Entity { + name: string; + label: string | null; + auth?: boolean; + fields: Field[]; + records: string; +} + +export type Schema = Map; + +interface DescribeResponse { + entities?: Record< + string, + { + label: string | null; + auth?: boolean; + records: string; + fields: Record>; + } + >; +} + +/** Read the app's data model from the A2APP surface. */ +export async function fetchSchema(project: ProjectRef): Promise { + const out: Schema = new Map(); + const res = await request(project, 'GET', '/api/_a2app/describe'); + if (res.status >= 300) return out; // app predates the adapter — callers degrade + + const parsed = JSON.parse(res.body) as DescribeResponse; + for (const [name, entity] of Object.entries(parsed.entities ?? {})) { + out.set(name, { + name, + label: entity.label, + auth: entity.auth === true, + records: entity.records, + fields: Object.entries(entity.fields ?? {}).map(([fieldName, spec]) => ({ + name: fieldName, + ...spec, + })), + }); + } + return out; +} + +/** Compact, one line per entity — for error messages and `data schema`. */ +export function describe(schema: Schema): string { + const lines: string[] = []; + for (const [name, entity] of schema) { + const fields = entity.fields + .filter((f) => !f.readOnly) + .map((f) => { + const type = f.entity !== undefined ? `->${f.entity}` : f.type; + return `${f.name}(${type}${f.required === true ? '*' : ''})`; + }) + .join(' '); + lines.push(` ${name}: ${fields}`); + } + return lines.join('\n'); +} + +/** Cheap did-you-mean (Levenshtein under a small threshold). */ +export function suggest(input: string, candidates: string[]): string | null { + const distance = (a: string, b: string): number => { + const prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let last = prev[0]!; + prev[0] = i; + for (let j = 1; j <= b.length; j++) { + const tmp = prev[j]!; + prev[j] = Math.min(prev[j]! + 1, prev[j - 1]! + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1)); + last = tmp; + } + } + return prev[b.length]!; + }; + let best: string | null = null; + let bestScore = Infinity; + for (const candidate of candidates) { + const d = distance(input.toLowerCase(), candidate.toLowerCase()); + if (d < bestScore) [best, bestScore] = [candidate, d]; + } + return bestScore <= Math.max(2, Math.floor(input.length / 3)) ? best : null; +} + +const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + +/** Local calendar date in the host's timezone, as YYYY-MM-DD. + * Deliberately NOT toISOString(), which is UTC and shifts the day near + * midnight — the exact bug class this effort exists to remove. Node has full + * Intl; PocketBase's VM does not (spec §3.1), which is why this lives here. */ +function localYmd(d: Date): string { + return new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d); +} + +/** Natural or absolute date → a PocketBase datetime, or null if unparseable. */ +export function parseDate(value: string, now: Date = new Date()): string | null { + const s = value.trim().toLowerCase(); + if (s === '') return ''; + if (/^\d{4}-\d{2}-\d{2}/.test(value.trim())) return value.trim(); + + const shift = (days: number): string => { + const d = new Date(now); + d.setDate(d.getDate() + days); + return `${localYmd(d)} 00:00:00.000Z`; + }; + + if (s === 'today' || s === 'now') return shift(0); + if (s === 'tomorrow') return shift(1); + if (s === 'yesterday') return shift(-1); + + let m = /^(?:in )?([+-]?\d+) ?(d|day|days|w|week|weeks|m|month|months)$/.exec(s); + if (m) { + const n = Number(m[1]); + const unit = m[2]!; + return shift(unit.startsWith('w') ? n * 7 : unit.startsWith('m') ? n * 30 : n); + } + + m = /^(?:next |this |on )?([a-z]+)$/.exec(s); + if (m) { + const word = m[1]!; + if (word === 'week') return shift(7); + if (word === 'month') return shift(30); + const target = DAYS.indexOf(word); + if (target >= 0) return shift(((target - now.getDay() + 7) % 7) || 7); + } + return null; +} + +/** Day-key fields are plain text with a declared YYYY-MM-DD format. */ +function isDayKey(field: Field): boolean { + return field.type === 'string' && field.format === 'YYYY-MM-DD'; +} + +export interface RefResolution { + id?: string; + error?: string; +} + +/** "To Do" → a record id. Ambiguity is an error listing the candidates, never + * a guess: across the shipped apps NO relation field has a unique index on its + * label, so a multi-match is the normal case, not an edge one (spec §3.6). */ +export async function resolveRef( + project: ProjectRef, + schema: Schema, + targetName: string, + value: string +): Promise { + if (/^[a-z0-9]{15}$/.test(value)) return { id: value }; + const target = schema.get(targetName); + if (target === undefined || target.label === null) return { id: value }; + const label = target.label; + + const filter = encodeURIComponent(`${label}="${value.replace(/"/g, '\\"')}"`); + const res = await request(project, 'GET', `${target.records}?perPage=10&filter=${filter}`); + if (res.status >= 300) return { id: value }; + const items = (JSON.parse(res.body) as { items?: Record[] }).items ?? []; + + if (items.length === 1) return { id: String(items[0]!['id']) }; + if (items.length === 0) { + const all = await request(project, 'GET', `${target.records}?perPage=25`); + const names = ((JSON.parse(all.body) as { items?: Record[] }).items ?? []) + .map((r) => String(r[label] ?? '')) + .filter(Boolean); + return { + error: `no ${targetName} with ${label}="${value}"${names.length ? ` — existing: ${names.join(', ')}` : ''}`, + }; + } + return { + error: `"${value}" matches ${items.length} ${targetName} records — pass an id instead: ${items + .map((r) => String(r['id'])) + .join(', ')}`, + }; +} + +/** Coerce a write body: human dates → ISO, relation labels → ids. + * Unknown fields and bad values are left alone — the app rejects those, and it + * must, because it is the only layer every caller goes through. */ +export async function coerceBody( + project: ProjectRef, + schema: Schema, + entityName: string, + body: Record +): Promise<{ body: Record; errors: string[] }> { + const entity = schema.get(entityName); + if (entity === undefined) return { body, errors: [] }; + const byName = new Map(entity.fields.map((f) => [f.name, f])); + const out: Record = { ...body }; + const errors: string[] = []; + + for (const [key, value] of Object.entries(body)) { + const field = byName.get(key); + if (field === undefined || typeof value !== 'string' || value === '') continue; + + if (field.type === 'datetime' || isDayKey(field)) { + const parsed = parseDate(value); + if (parsed === null) { + errors.push(`--${key} "${value}" is not a date. Try an ISO date, or today/tomorrow/in 3 days/next monday.`); + } else { + out[key] = isDayKey(field) ? parsed.slice(0, 10) : parsed; + } + continue; + } + + if (field.type === 'ref' && field.entity !== undefined) { + const resolved = await resolveRef(project, schema, field.entity, value); + if (resolved.error !== undefined) errors.push(`--${key}: ${resolved.error}`); + else if (resolved.id !== undefined) out[key] = resolved.id; + } + } + return { body: out, errors }; +} + +/** Fallback for apps whose adapter predates the write guard: which non-blank + * requested values are missing or empty in what came back? */ +export function droppedFields(sent: Record, saved: Record): string[] { + const out: string[] = []; + for (const [key, value] of Object.entries(sent)) { + if (value === '' || value === null || value === undefined) continue; + const stored = saved[key]; + if (stored === undefined || stored === '' || stored === null) out.push(key); + } + return out; +} From b55d44f36861156fbda4aed65b383339df1fd880 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Thu, 30 Jul 2026 10:57:12 +0100 Subject: [PATCH 2/2] lint fixes --- .../blueprint/pb/pb_hooks/_a2app.pb.js | 2 +- .../blueprint/pb/pb_hooks/_a2app_lib.js | 24 +- .../blueprint/pb/pb_hooks/_a2app_rules.js | 2 +- .../blueprint/pb/pb_hooks/_system.pb.js | 20 +- living-ui-v2/eslint.config.js | 4 + living-ui-v2/package-lock.json | 3120 ++++++++++++++--- living-ui-v2/tools/src/commands/validate.ts | 11 - 7 files changed, 2707 insertions(+), 476 deletions(-) diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js index b8f97ba1..c7d45320 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app.pb.js @@ -24,7 +24,7 @@ routerAdd('GET', '/api/_a2app', (e) => { let manifest = {}; try { manifest = JSON.parse(toString($os.readFile($filepath.join(__hooks, '..', '..', 'manifest.json')))); - } catch (err) { + } catch { manifest = {}; } return e.json(200, { diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js index f5e50245..29349ce7 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_lib.js @@ -106,7 +106,7 @@ function fieldsOf(app, collection) { entry.maxSelect = maxSelect; try { entry.target = String(app.findCollectionByNameOrId(f.collectionId).name); - } catch (err) { + } catch { entry.target = null; } } @@ -167,13 +167,13 @@ function idempotencyKey(e, collectionName) { try { var headers = e.requestInfo().headers || {}; supplied = String(headers.idempotency_key || ''); - } catch (err) { + } catch { supplied = ''; } if (supplied === '') { try { supplied = String(e.request.header.get('Idempotency-Key') || ''); - } catch (err) { + } catch { supplied = ''; } } @@ -193,7 +193,7 @@ function idempotencySeen(key) { if (!entry) return null; if (Date.now() - Number(entry.ts || 0) > IDEMPOTENCY_TTL_MS) return null; return entry; - } catch (err) { + } catch { return null; } } @@ -202,7 +202,7 @@ function idempotencyRecord(key, recordId) { if (!key) return; try { $app.store().set(key, { id: String(recordId), ts: Date.now() }); - } catch (err) { + } catch { /* never break a write over bookkeeping */ } } @@ -215,7 +215,7 @@ function guardRequest(e) { try { method = String(e.request.method || '').toUpperCase(); path = String((e.request.url && e.request.url.path) || ''); - } catch (err) { + } catch { return e.next(); } if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT') return e.next(); @@ -242,7 +242,7 @@ function guardRequest(e) { try { collection = e.app.findCollectionByNameOrId(m[1]); body = e.requestInfo().body; - } catch (err) { + } catch { return e.next(); // unknown collection / unparseable body — let PocketBase answer } if (!collection || !body || typeof body !== 'object' || Array.isArray(body)) return e.next(); @@ -345,11 +345,11 @@ function logAction(entry) { var existing = ''; try { existing = toString($os.readFile(file)); - } catch (err) { + } catch { existing = ''; } $os.writeFile(file, existing + JSON.stringify(entry) + '\n', 0o644); - } catch (err) { + } catch { // Logging must never break a write. } } @@ -361,13 +361,13 @@ function agentIdOf(e) { try { var h = e.requestInfo().headers || {}; if (h.x_lui_agent) return String(h.x_lui_agent).slice(0, 120); - } catch (err) { + } catch { /* fall through */ } try { var id = e.request.header.get('X-LUI-Agent'); if (id) return String(id).slice(0, 120); - } catch (err) { + } catch { /* fall through */ } return 'unknown'; @@ -436,7 +436,7 @@ function describeApp(app) { toString($os.readFile($filepath.join(__hooks, '..', '..', 'operations.json'))) ); operations = manifest.operations || []; - } catch (err) { + } catch { operations = []; } diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js index 2d729e1d..a99ac8cc 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_a2app_rules.js @@ -194,7 +194,7 @@ function divergences(fields, body, read) { var stored; try { stored = read(key); - } catch (err) { + } catch { stored = null; } if (isBlank(stored) || String(stored) === '') { diff --git a/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js index 02f2809a..cf89a4fe 100644 --- a/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js +++ b/living-ui-v2/blueprint/pb/pb_hooks/_system.pb.js @@ -40,7 +40,7 @@ routerUse((e) => { var origin = ''; try { origin = String(e.request.header.get('Origin') || ''); - } catch (err) { + } catch { origin = ''; } @@ -76,7 +76,7 @@ routerUse((e) => { var method = ''; try { method = String(e.request.method || '').toUpperCase(); - } catch (err) { + } catch { method = ''; } if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { @@ -109,7 +109,7 @@ routerUse((e) => { var path = ''; try { path = String((e.request.url && e.request.url.path) || ''); - } catch (err) { + } catch { return e.next(); } if (path.indexOf('/api/ops/') !== 0) return e.next(); @@ -120,7 +120,7 @@ routerUse((e) => { toString($os.readFile($filepath.join(__hooks, '..', '..', 'manifest.json'))) ); authMode = String(manifest.authMode || 'none'); - } catch (err) { + } catch { authMode = 'none'; } if (authMode !== 'multi-user') return e.next(); @@ -128,14 +128,14 @@ routerUse((e) => { var authed = false; try { if (e.auth) authed = true; - } catch (err) { + } catch { /* fall through */ } if (!authed) { try { var info = e.requestInfo(); if (info && info.auth) authed = true; - } catch (err) { + } catch { /* fall through */ } } @@ -172,7 +172,7 @@ routerUse((e) => { method = String(e.request.method || '').toUpperCase(); path = String((e.request.url && e.request.url.path) || ''); origin = String(e.request.header.get('Origin') || ''); - } catch (err) { + } catch { return e.next(); } if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT' && method !== 'DELETE') { @@ -189,7 +189,7 @@ routerUse((e) => { var expected = ''; try { expected = toString($os.readFile($filepath.join(__hooks, '..', '..', '.agent-token'))).trim(); - } catch (err) { + } catch { expected = ''; } if (expected === '') return e.next(); // no token provisioned — do not lock the app out @@ -197,7 +197,7 @@ routerUse((e) => { var presented = ''; try { presented = String(e.request.header.get('X-LUI-Token') || '').trim(); - } catch (err) { + } catch { presented = ''; } if (presented !== expected) { @@ -256,7 +256,7 @@ routerAdd('POST', '/api/_console', (e) => { let origin = ''; try { origin = String(e.request.header.get('Origin') || ''); - } catch (err) { + } catch { origin = ''; } if (origin !== '' && !ALLOWED_ORIGIN.test(origin)) { diff --git a/living-ui-v2/eslint.config.js b/living-ui-v2/eslint.config.js index b41343bb..78ba33fc 100644 --- a/living-ui-v2/eslint.config.js +++ b/living-ui-v2/eslint.config.js @@ -15,6 +15,10 @@ export default tseslint.config( files: ['**/pb_hooks/**/*.js', '**/pb_migrations/**/*.js'], rules: { '@typescript-eslint/triple-slash-reference': 'off', + // require() is not a style choice here. Hook callbacks run in isolated + // VMs that cannot see their own file's scope, so shared code is only + // reachable via require() INSIDE each callback. ESM is unavailable. + '@typescript-eslint/no-require-imports': 'off', }, }, ); diff --git a/living-ui-v2/package-lock.json b/living-ui-v2/package-lock.json index e0e493fb..71e2b035 100644 --- a/living-ui-v2/package-lock.json +++ b/living-ui-v2/package-lock.json @@ -72,7 +72,6 @@ "examples/ci-demo/frontend": { "name": "lui-app-ci-demo", "version": "0.1.0", - "extraneous": true, "dependencies": { "@radix-ui/react-dialog": "^1.1.0", "class-variance-authority": "^0.7.0", @@ -140,7 +139,7 @@ }, "kit": { "name": "@livingui/kit", - "version": "0.4.0", + "version": "0.5.0", "devDependencies": { "@radix-ui/react-dialog": "^1.1.0", "@types/react": "^19.0.0", @@ -158,313 +157,1230 @@ "react-dom": "^19.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6.9.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=6.9.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "license": "MIT", "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=18.18.0" + "node": ">=6.9.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18.18.0" + "node": ">=6.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=12.22" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18.18" + "node": ">=6.9.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@livingui/kit": { - "resolved": "kit", - "link": true - }, - "node_modules/@livingui/tools": { - "resolved": "tools", - "link": true - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", - "integrity": "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==", + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@radix-ui/react-context": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.1.tgz", - "integrity": "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==", + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", - "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.4", - "@radix-ui/react-context": "1.2.1", - "@radix-ui/react-dismissable-layer": "1.1.17", - "@radix-ui/react-focus-guards": "1.1.5", - "@radix-ui/react-focus-scope": "1.1.14", - "@radix-ui/react-id": "1.1.3", - "@radix-ui/react-portal": "1.1.15", - "@radix-ui/react-presence": "1.1.9", - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-slot": "1.3.1", - "@radix-ui/react-use-controllable-state": "1.2.5", - "@radix-ui/react-use-layout-effect": "1.1.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.17.tgz", - "integrity": "sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@livingui/kit": { + "resolved": "kit", + "link": true + }, + "node_modules/@livingui/tools": { + "resolved": "tools", + "link": true + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", + "integrity": "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.1.tgz", + "integrity": "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.17.tgz", + "integrity": "sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3", + "@radix-ui/react-use-effect-event": "0.0.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.5.tgz", + "integrity": "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.14.tgz", + "integrity": "sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-callback-ref": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.3.tgz", + "integrity": "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.15.tgz", + "integrity": "sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.9.tgz", + "integrity": "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.4", - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-use-callback-ref": "1.1.3", - "@radix-ui/react-use-effect-event": "0.0.4" + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.8.tgz", + "integrity": "sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.1" }, "peerDependencies": { "@types/react": "*", @@ -481,230 +1397,765 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.5.tgz", - "integrity": "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==", + "node_modules/@radix-ui/react-slot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.1.tgz", + "integrity": "sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.3.tgz", + "integrity": "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.5.tgz", + "integrity": "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.4", + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.4.tgz", + "integrity": "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.3.tgz", + "integrity": "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">= 20" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.14.tgz", - "integrity": "sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.4", - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-use-callback-ref": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.3.tgz", - "integrity": "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.15.tgz", - "integrity": "sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-use-layout-effect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.9.tgz", - "integrity": "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.8.tgz", - "integrity": "sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.3.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.1.tgz", - "integrity": "sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.4" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.3.tgz", - "integrity": "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.5.tgz", - "integrity": "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-use-effect-event": "0.0.4", - "@radix-ui/react-use-layout-effect": "1.1.3" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.4.tgz", - "integrity": "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.3.tgz", - "integrity": "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "dependencies": { + "@babel/types": "^7.28.2" } }, "node_modules/@types/estree": { @@ -735,7 +2186,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -745,7 +2196,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1033,6 +2484,27 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1100,7 +2572,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -1116,6 +2587,19 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz", + "integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", @@ -1127,6 +2611,40 @@ "concat-map": "0.0.1" } }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1137,6 +2655,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1158,7 +2697,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "clsx": "^2.1.1" @@ -1171,7 +2709,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1204,6 +2741,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1223,7 +2767,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1251,13 +2795,95 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.398", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", + "integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1515,11 +3141,35 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1551,6 +3201,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1628,6 +3285,23 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", @@ -1643,57 +3317,344 @@ "url": "https://github.com/sponsors/nodeca" } ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -1719,6 +3680,30 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lui-app-ci-demo": { + "resolved": "examples/ci-demo/frontend", + "link": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1739,6 +3724,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1746,6 +3750,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1829,6 +3843,13 @@ "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", @@ -1893,9 +3914,37 @@ "version": "0.26.9", "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.9.tgz", "integrity": "sha512-Tiv1/hNuUzRdvT0d8hF03dfzuefQ1WdSRp1A1q3wzFC/WYhQcbU/Qlaubl/3ZDo6xvFXBS8JAgBS/L+ms7nkVQ==", - "dev": true, "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1936,7 +3985,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1946,7 +3994,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -1955,11 +4002,20 @@ "react": "^19.2.8" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "dev": true, "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -1985,7 +4041,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dev": true, "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", @@ -2008,7 +4063,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dev": true, "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", @@ -2037,11 +4091,55 @@ "node": ">=4" } }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -2080,6 +4178,16 @@ "node": ">=8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2110,13 +4218,33 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2151,7 +4279,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -2212,6 +4339,37 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2226,7 +4384,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -2248,7 +4405,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -2267,6 +4423,81 @@ } } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2293,6 +4524,13 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/living-ui-v2/tools/src/commands/validate.ts b/living-ui-v2/tools/src/commands/validate.ts index 16dbda61..1aa3fcd7 100644 --- a/living-ui-v2/tools/src/commands/validate.ts +++ b/living-ui-v2/tools/src/commands/validate.ts @@ -32,17 +32,6 @@ interface GateError { * - `devDependencies` and `scripts` are frozen (build tooling is platform- * owned); lifecycle script keys are forbidden outright. */ -const BASELINE_DEPS = new Set([ - '@radix-ui/react-dialog', - 'class-variance-authority', - 'clsx', - 'pocketbase', - 'react', - 'react-dom', - 'tailwind-merge', -]); - - const BASELINE_DEV_DEPS = new Set([ '@tailwindcss/vite', '@types/react',