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..c7d45320 --- /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 { + 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..29349ce7 --- /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 { + 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 { + supplied = ''; + } + if (supplied === '') { + try { + supplied = String(e.request.header.get('Idempotency-Key') || ''); + } catch { + 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 { + return null; + } +} + +function idempotencyRecord(key, recordId) { + if (!key) return; + try { + $app.store().set(key, { id: String(recordId), ts: Date.now() }); + } catch { + /* 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 { + 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 { + 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 { + existing = ''; + } + $os.writeFile(file, existing + JSON.stringify(entry) + '\n', 0o644); + } catch { + // 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 { + /* fall through */ + } + try { + var id = e.request.header.get('X-LUI-Agent'); + if (id) return String(id).slice(0, 120); + } catch { + /* 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 { + 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..a99ac8cc --- /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 { + 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..cf89a4fe 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 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'); + // 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 { + 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 { + 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 { + 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 { + authMode = 'none'; + } + if (authMode !== 'multi-user') return e.next(); + + var authed = false; + try { + if (e.auth) authed = true; + } catch { + /* fall through */ + } + if (!authed) { + try { + var info = e.requestInfo(); + if (info && info.auth) authed = true; + } catch { + /* 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) => { + 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 { + 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 { + 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 { + 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 { + 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/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/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..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', @@ -143,6 +132,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 +371,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; +}