commit b3b6bf3f70db36afa3fb74e50d382b5a3a3aee73 Author: iceBear67 Date: Sun Aug 9 04:00:13 2026 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json new file mode 100644 index 0000000..97b98b5 --- /dev/null +++ b/.grok-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "grok-glance", + "version": "0.1.0", + "description": "A passkey-guarded web dashboard that lets you glance at what Grok Build is doing from your phone, and approve or deny risky tool calls remotely.", + "author": { + "name": "grok-glance" + }, + "license": "MIT", + "keywords": [ + "grok-glance", + "glance dashboard", + "webauthn passkey", + "remote approval", + "session monitor" + ], + "hooks": "hooks/hooks.json" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f643ec --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# grok-glance + +A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what +an agent is doing from your phone — and tap **approve** or **deny** when it wants to run something +risky. + +It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files, +or drive a session. + +``` +┌────────────────────────────┐ +│ ● grok-glance 2 sessions│ +├────────────────────────────┤ +│ Waiting on you 62s │ +│ Bash · in remote-grok │ +│ rm -rf ./dist │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │ +│ [ Deny ] [ Approve ] │ +├────────────────────────────┤ +│ remote-grok working │ +│ ~/src/remote-grok │ +│ Last asked: fix the flaky │ +│ ⟳ Read 4s │ +│ server/src/state.ts │ +│ 12 tools 0 failed 0 ✗ │ +├────────────────────────────┤ +│ Activity │ +│ ● Read state.ts 14:22 │ +│ ● Bash npm test 14:21 │ +└────────────────────────────┘ +``` + +## Requirements + +- Node.js 20 or newer, and npm. +- Grok Build. +- For phone access: [Tailscale](https://tailscale.com/) on both the machine and the phone. See + [Why Tailscale](#why-tailscale-and-not-just-the-lan-ip) — a LAN IP genuinely cannot work. + +## Install + +```sh +git clone grok-glance +cd grok-glance +npm install && npm run build +``` + +The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required; +the daemon serves the dashboard itself. + +Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so +for a local checkout the shortest path is a one-entry catalog. Create +`.grok-plugin/marketplace.json` in a directory that contains your checkout: + +```json +{ + "name": "local", + "description": "Local plugins", + "owner": { "name": "me" }, + "plugins": [ + { + "name": "grok-glance", + "description": "Passkey-guarded phone dashboard for Grok Build.", + "category": "monitoring", + "source": { "type": "local", "path": "./grok-glance" } + } + ] +} +``` + +…then add that marketplace and install `grok-glance` from Grok Build's `/plugin` interface. + +Once installed, the daemon starts by itself: the `SessionStart` hook boots it in the background on +the first session after installation. + +## Get it onto your phone + +The daemon binds to `127.0.0.1` only and never opens a port to your network. Tailscale Serve +publishes it inside your tailnet with real TLS: + +```sh +tailscale serve --bg 127.0.0.1:8791 +tailscale serve status # note the https://..ts.net URL +``` + +Tell grok-glance which origin it is being served on — this is also the WebAuthn relying-party ID, +so it has to be exact: + +```sh +node bin/glance set-origin https://..ts.net +``` + +## Enrol the phone + +```sh +node bin/glance enroll +``` + +That prints a URL and an 8-character code, good for 10 minutes, single use: + +``` + Open this on your phone: + + https://mybox.tailnet-1234.ts.net/?enroll + + Enrollment code: K7QM4RTX + Valid for: 10 minutes (single use) +``` + +Open the URL on the phone, type the code, tap **Create passkey**, and confirm with Face ID / a +fingerprint / the device PIN. From then on the phone unlocks the dashboard with that passkey and +nothing else gets in. + +Repeat for each device you want. `node bin/glance devices` lists them; `node bin/glance revoke +` removes one (and kills its live session immediately). + +## What the dashboard shows + +- **Now** — the workspace, its state (working / waiting on you / idle / error / ended), the last + thing you asked, the tool currently running with a live elapsed timer, and running counts of + tools, failures and denials. +- **Sessions** — one row per live session when there is more than one; tap to filter. +- **Activity** — a timeline of prompts, tool calls with durations, failures, permission denials, + notifications, subagents, compactions, session start/end. +- **Pending approvals** — a card per waiting tool call, with the command, a countdown, and two + large buttons. + +Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a +phone that slept through twenty events still wakes up showing the truth. + +## Remote approve / deny + +Off by default. Turn it on from the phone's settings panel, or: + +```sh +node bin/glance approval risky # Bash, Write, Edit, MultiEdit, NotebookEdit +node bin/glance approval all # every tool call — noisy +node bin/glance approval off +``` + +Defaults worth knowing: + +| Behaviour | Default | Changeable from the phone | Why | +|---|---|---|---| +| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. | +| On timeout | allow | yes | Flip to *deny* if you would rather fail closed. | +| Timeout | 90s | no — edit `config.json` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. | +| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. | + +**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon +down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls +actually blocked, use Grok Build's own permission settings. + +## CLI + +`bin/glance` is plain Node with no dependencies. Run it as `node bin/glance `. + +| Command | What it does | +|---|---| +| `status` | Running? On which origin, with how many devices? | +| `up` | Start the daemon in the background | +| `serve` | Run it in the foreground (for debugging) | +| `stop` | Stop it | +| `logs` | Last 60 lines of the daemon log | +| `enroll` | Mint a one-time enrolment code and URL | +| `set-origin ` | Set the public https origin and RP ID | +| `devices` | List enrolled devices | +| `revoke ` | Revoke a device | +| `approval ` | Set the approval policy | +| `sync-hooks` | Rewrite hook URLs after changing the port | + +## Files and configuration + +Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set it: + +| File | Contents | +|---|---| +| `config.json` | Port, bind host, public origin, RP ID, approval settings | +| `credentials.json` | Enrolled passkeys — credential IDs, public keys, counters. No secrets of yours. | +| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens | +| `secret.key` | 32-byte HMAC key used to sign session cookies | +| `admin.token` | Rotated every daemon start; authenticates the CLI | +| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB | +| `daemon.log` | Daemon stdout/stderr | + +Three environment variables override `config.json`, which is mostly useful for testing a second +instance without touching your real one: + +| Variable | Effect | +|---|---| +| `GLANCE_HOME` | Where all of the above lives. Read by the daemon, the CLI, and the hook scripts. | +| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) | +| `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted | + +To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in +`hooks/hooks.json` match. Restart the daemon afterwards. + +## Security notes + +**Network surface.** The daemon listens on `127.0.0.1:8791` and makes no outbound connections of +its own. Three classes of caller: + +| Path | Caller | Authentication | +|---|---|---| +| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only | +| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header | +| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` | + +`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies +remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by +address alone. + +**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request +arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions +last 30 days; revoking a device drops its session at once. + +**Cross-site defence.** Every POST must carry exactly `content-type: application/json`, which is +not a CORS-safelisted type, so a hostile page cannot post here without a preflight that is never +answered. `/api/*` additionally requires an `x-glance-csrf` header. The pages themselves are served +with a strict CSP (`script-src 'self'`, no framing, no form actions) and `no-store`. + +**User verification is required**, for both enrolment and sign-in: the phone asks for a biometric +or PIN every time, so a stolen unlocked phone is not automatically a way in. Passkeys are created +as resident keys, so the phone offers the right one without you typing a username. + +**Rate limits.** Authentication attempts are capped (40 per 5 minutes), enrolment attempts more +tightly (12 per 5 minutes, and a code burns itself after 5 wrong guesses). The limiter is keyed +globally on purpose: behind a tunnel every request arrives from `127.0.0.1`, so per-IP buckets +would be one bucket wearing a hat. + +**What crosses the wire.** Tool names, truncated arguments, prompt first lines, file paths, +durations, and exit statuses — a summary, not a transcript. Known secret shapes are redacted before +storage: `sk-`/`rk-`/`pk-` keys, `xai-` keys, GitHub and Slack tokens, AWS access key IDs, JWTs, +PEM private-key blocks, `Authorization:`/`Bearer` headers, and `password`/`secret`/`token`/`api_key` +assignments. This is a filter, not a guarantee — a secret in an unusual shape will show up in the +timeline. Treat the dashboard as being as sensitive as your terminal. + +**Threat model.** grok-glance assumes the machine it runs on is trusted. It protects against +someone else on your tailnet, or a browser tab you left open, reaching the dashboard. It does not +protect against a local attacker who can read `~/.grok/glance` — with `admin.token` they can enrol +their own device. + +### Why Tailscale, and not just the LAN IP? + +WebAuthn will not run outside a secure context, and — separately — a bare IP address cannot be a +relying-party ID. So `http://192.168.1.20:8791` can never hold a passkey, no matter what the +browser is willing to render. You need a hostname with valid TLS. Tailscale Serve gives you one for +free, with the tunnel closed to everything outside your tailnet. + +Any other route to a real https hostname works too: set `origin` in `config.json` (or use +`set-origin`) to whatever your reverse proxy terminates on. If you put grok-glance behind a proxy +reachable from the public internet, the passkey gate is the only thing standing in front of it. + +Changing the origin changes the RP ID, and **passkeys are bound to the RP ID** — existing devices +stop working and must be enrolled again. + +## Hook wiring + +`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they +POST straight into the daemon with no process spawn, so they cost close to nothing per tool call +and quietly do nothing when the daemon is down. + +Two exceptions: + +- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon. +- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a + `command` entry matching only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs + `bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only + documented way to return a deny decision, so the gate has to be a spawned process; keeping the + match narrow means the cost is paid only for calls that could actually need a tap. + +The hook scripts use nothing but the Node standard library and always exit 0 unless they are +deliberately denying. + +## Deliberately omitted + +Not oversights — decisions: + +- **Sending prompts or steering the session.** Hooks cannot inject input, so a phone-to-agent + channel would need a second transport and a much larger threat model. +- **Full transcripts and tool output.** Summaries only. Streaming assistant text through a phone + would mean shipping your codebase through it. +- **Push notifications.** Needs a VAPID key, a service worker, and a subscription store, for a + dashboard you open on purpose. +- **Multi-user accounts and roles.** One user, several devices. +- **Diff views, file browsing, cost/token charts, log search, session resume.** All out of scope + for a glance. +- **Editing the risky-tool pattern from the phone.** Shown but not editable: a typo'd regex there + would silently change what gets gated. Edit `config.json` instead. + +## Licence + +MIT. diff --git a/bin/glance b/bin/glance new file mode 100755 index 0000000..fa10fda --- /dev/null +++ b/bin/glance @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * grok-glance CLI. + * + * Privileged operations (minting enrollment codes, revoking devices) are authenticated with + * a local admin token read from $GLANCE_HOME/admin.token, not by "is this request from + * localhost". That distinction matters: `tailscale serve` proxies remote traffic to + * 127.0.0.1, so the daemon cannot tell a local caller from a tunnelled one by address alone. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { + PLUGIN_ROOT, + baseUrl, + glanceHome, + isDaemonUp, + readConfig, + sleep, +} from "./glance-lib.mjs"; + +const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js"); +const cfg = readConfig(); +const cmd = process.argv[2] ?? "status"; +const args = process.argv.slice(3); + +function adminToken() { + try { + return fs.readFileSync(path.join(glanceHome(), "admin.token"), "utf8").trim(); + } catch { + return null; + } +} + +async function api(pathname, { method = "GET", body, admin = false } = {}) { + const headers = { "content-type": "application/json" }; + if (admin) { + const token = adminToken(); + if (!token) { + throw new Error("no admin token found - is the daemon running? try `glance up`"); + } + headers["x-glance-admin"] = token; + } + const res = await fetch(`${baseUrl(cfg)}${pathname}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + if (!res.ok) throw new Error(data?.error ?? `${res.status} ${res.statusText}`); + return data; +} + +function requireBuild() { + if (!fs.existsSync(SERVER_ENTRY)) { + console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`); + process.exit(1); + } +} + +async function ensureUp() { + if (await isDaemonUp(cfg)) return true; + requireBuild(); + const home = glanceHome(); + fs.mkdirSync(home, { recursive: true }); + const logFd = fs.openSync(path.join(home, "daemon.log"), "a"); + const child = spawn(process.execPath, [SERVER_ENTRY], { + detached: true, + stdio: ["ignore", logFd, logFd], + }); + child.unref(); + for (let i = 0; i < 40; i++) { + await sleep(200); + if (await isDaemonUp(cfg, 300)) return true; + } + console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`); + return false; +} + +switch (cmd) { + case "serve": + case "start": { + requireBuild(); + await import(SERVER_ENTRY); + break; + } + + case "up": { + if (await ensureUp()) console.log(`grok-glance running on ${baseUrl(cfg)}`); + else process.exit(1); + break; + } + + case "stop": { + if (!(await isDaemonUp(cfg))) { + console.log("not running"); + break; + } + await api("/local/shutdown", { method: "POST", admin: true }); + console.log("stopped"); + break; + } + + case "status": { + if (!(await isDaemonUp(cfg))) { + console.log(`grok-glance: not running (port ${cfg.port})`); + console.log("start it with: glance up"); + break; + } + const s = await api("/local/status", { admin: true }); + console.log(`grok-glance ${s.version} on ${baseUrl(cfg)}`); + console.log(` public origin : ${s.origin ?? "(not configured - see README)"}`); + console.log(` rp id : ${s.rpId ?? "(not configured)"}`); + console.log(` devices : ${s.devices}`); + console.log(` approval mode : ${s.approval.mode}`); + console.log(` watchers : ${s.watchers}`); + console.log(` sessions : ${s.sessions}`); + console.log(` events kept : ${s.events}`); + if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll"); + break; + } + + case "enroll": { + if (!(await ensureUp())) process.exit(1); + const out = await api("/local/enroll", { method: "POST", admin: true }); + console.log("\n Open this on your phone:\n"); + console.log(` ${out.url}\n`); + console.log(` Enrollment code: ${out.code}`); + console.log(` Valid for: ${Math.round(out.expiresInMs / 60000)} minutes (single use)\n`); + if (!out.originConfigured) { + console.log(" Note: no public origin configured yet, so the URL above is localhost."); + console.log(" Set one up first (see README), e.g.:\n"); + console.log(" tailscale serve --bg 127.0.0.1:" + cfg.port); + console.log(" glance set-origin https://..ts.net\n"); + } + break; + } + + case "set-origin": { + const origin = args[0]; + if (!origin) { + console.error("usage: glance set-origin https://your-box.tailnet.ts.net"); + process.exit(1); + } + if (!(await ensureUp())) process.exit(1); + const out = await api("/local/origin", { method: "POST", admin: true, body: { origin } }); + console.log(`origin : ${out.origin}`); + console.log(`rp id : ${out.rpId}`); + console.log("\nEnrolled devices are bound to the rp id. Changing it invalidates them."); + break; + } + + case "devices": { + if (!(await isDaemonUp(cfg))) { + console.error("not running"); + process.exit(1); + } + const out = await api("/local/devices", { admin: true }); + if (!out.devices.length) { + console.log("no devices enrolled - run: glance enroll"); + break; + } + for (const d of out.devices) { + console.log(`${d.id.slice(0, 16)}… ${d.label.padEnd(24)} added ${new Date(d.createdAt).toISOString().slice(0, 10)} last seen ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`); + } + break; + } + + case "revoke": { + if (!args[0]) { + console.error("usage: glance revoke "); + process.exit(1); + } + const out = await api("/local/devices/revoke", { + method: "POST", + admin: true, + body: { idPrefix: args[0] }, + }); + console.log(`revoked ${out.revoked} device(s)`); + break; + } + + case "approval": { + const mode = args[0]; + if (!["off", "risky", "all"].includes(mode)) { + console.error("usage: glance approval "); + process.exit(1); + } + const out = await api("/local/approval", { method: "POST", admin: true, body: { mode } }); + console.log(`approval mode: ${out.mode}`); + break; + } + + case "sync-hooks": { + const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json"); + const doc = JSON.parse(fs.readFileSync(file, "utf8")); + let changed = 0; + for (const groups of Object.values(doc.hooks ?? {})) { + for (const group of groups) { + for (const h of group.hooks ?? []) { + if (h.type === "http" && typeof h.url === "string") { + const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`); + if (next !== h.url) changed++; + h.url = next; + } + } + } + } + fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n"); + console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`); + break; + } + + case "logs": { + const file = path.join(glanceHome(), "daemon.log"); + if (!fs.existsSync(file)) { + console.log("no log yet"); + break; + } + process.stdout.write(fs.readFileSync(file, "utf8").split("\n").slice(-60).join("\n") + "\n"); + break; + } + + default: + console.log(`grok-glance - glance at Grok Build from your phone + + glance up start the daemon in the background + glance serve run it in the foreground + glance stop stop it + glance status show what is running + glance enroll mint a one-time code to enrol a phone + glance set-origin set the public https origin (and webauthn rp id) + glance devices list enrolled devices + glance revoke revoke a device + glance approval remote approval policy + glance sync-hooks rewrite hook urls after a port change + glance logs tail the daemon log +`); +} diff --git a/bin/glance-approve.mjs b/bin/glance-approve.mjs new file mode 100755 index 0000000..7437fec --- /dev/null +++ b/bin/glance-approve.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * PreToolUse hook: the remote approval gate. + * + * Asks the daemon what to do with this tool call. The daemon holds the request open while + * your phone decides, then answers allow/deny. Every failure path here is fail-open — + * a daemon that is down, slow, or confused must not be able to block your agent. + * + * Denying is the only outcome that changes behaviour, and approving only lets through a + * call Grok was already about to make. This hook can never introduce a new command. + */ + +import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs"; + +function allow() { + process.exit(0); +} + +function deny(reason) { + // Belt and braces: the documented deny signals are a JSON decision on stdout *and* + // exit code 2. We emit both so a change in precedence cannot silently allow. + process.stdout.write( + JSON.stringify({ decision: "deny", reason: reason || "Denied from grok-glance" }), + ); + process.exit(2); +} + +const payload = envEnvelope(await readStdinJson()); +const cfg = readConfig(); + +// Stay inside the hook timeout declared in hooks/hooks.json (125s). +const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000); + +try { + const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs); + if (data && data.decision === "deny") deny(data.reason); + allow(); +} catch { + allow(); +} diff --git a/bin/glance-lib.mjs b/bin/glance-lib.mjs new file mode 100644 index 0000000..5d5a815 --- /dev/null +++ b/bin/glance-lib.mjs @@ -0,0 +1,107 @@ +/** + * Shared helpers for the grok-glance hook scripts and CLI. + * + * Deliberately dependency-free and stdlib-only: these run on the critical path of + * every Grok Build tool call, so they must start fast and never wedge a session. + */ + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); + +export const DEFAULT_PORT = 8791; + +/** + * State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the + * CLI (which does not) always agree on where config, credentials and events are. + */ +export function glanceHome() { + if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME); + return path.join(os.homedir(), ".grok", "glance"); +} + +export function readConfig() { + const file = path.join(glanceHome(), "config.json"); + let raw = {}; + try { + raw = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + // No config yet, or unreadable: defaults are always usable. + } + const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT); + return { + ...raw, + port: Number.isFinite(port) ? port : DEFAULT_PORT, + host: raw.host ?? "127.0.0.1", + }; +} + +export function baseUrl(cfg = readConfig()) { + return `http://127.0.0.1:${cfg.port}`; +} + +/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */ +export async function readStdinJson() { + if (process.stdin.isTTY) return {}; + const chunks = []; + try { + for await (const chunk of process.stdin) chunks.push(chunk); + } catch { + return {}; + } + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +/** + * Grok Build also passes the event in the environment. We merge it in so a payload that + * is missing fields (or absent entirely) still produces a usable event. + */ +export function envEnvelope(payload) { + return { + hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown", + sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown", + workspaceRoot: + payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(), + cwd: payload.cwd ?? process.cwd(), + hookName: process.env.GROK_HOOK_NAME ?? undefined, + ...payload, + }; +} + +export async function postJson(url, body, timeoutMs) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await res.text(); + if (!text) return { status: res.status, data: null }; + try { + return { status: res.status, data: JSON.parse(text) }; + } catch { + return { status: res.status, data: null }; + } +} + +export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) { + try { + const res = await fetch(`${baseUrl(cfg)}/healthz`, { + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/bin/glance-up.mjs b/bin/glance-up.mjs new file mode 100755 index 0000000..5e42ee5 --- /dev/null +++ b/bin/glance-up.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * SessionStart hook: make sure the glance daemon is running, then record the event. + * + * This is the only hook that spawns anything. It always exits 0 — a monitoring + * dashboard must never be the reason a Grok Build session fails to start. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { + PLUGIN_ROOT, + baseUrl, + envEnvelope, + glanceHome, + isDaemonUp, + postJson, + readConfig, + readStdinJson, + sleep, +} from "./glance-lib.mjs"; + +const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js"); + +async function ensureDaemon(cfg) { + if (await isDaemonUp(cfg)) return true; + + if (!fs.existsSync(SERVER_ENTRY)) { + // Not built yet. Say so once, on stderr, where it is recorded but harmless. + process.stderr.write( + `[grok-glance] not built yet - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`, + ); + return false; + } + + const home = glanceHome(); + fs.mkdirSync(home, { recursive: true }); + const logFd = fs.openSync(path.join(home, "daemon.log"), "a"); + + const child = spawn(process.execPath, [SERVER_ENTRY], { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, GLANCE_STARTED_BY: "hook" }, + }); + child.unref(); + + // Give it a moment to bind before the first http hook fires. + for (let i = 0; i < 40; i++) { + await sleep(200); + if (await isDaemonUp(cfg, 300)) return true; + } + return false; +} + +const payload = envEnvelope(await readStdinJson()); +const cfg = readConfig(); + +try { + const up = await ensureDaemon(cfg); + if (up) { + await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500); + } +} catch { + // Fail open, always. +} + +process.exit(0); diff --git a/commands/glance.md b/commands/glance.md new file mode 100644 index 0000000..6ee5d55 --- /dev/null +++ b/commands/glance.md @@ -0,0 +1,27 @@ +--- +description: Set up or control the grok-glance phone dashboard (status, enrol a device, approval policy) +argument-hint: [status | up | stop | enroll | set-origin | devices | revoke | approval off|risky|all | logs] +--- + +Run the grok-glance CLI and report the result plainly. + +The CLI lives at `$GROK_PLUGIN_ROOT/bin/glance`. The user asked for: **$ARGUMENTS** + +If no argument was given, treat it as `status`. + +Then: + +1. Run `node "$GROK_PLUGIN_ROOT/bin/glance" $ARGUMENTS`. +2. If it says the plugin is not built, run `npm install && npm run build` in `$GROK_PLUGIN_ROOT` + (this takes a minute or two) and try again. +3. Report what came back. For `enroll`, show the URL and the code verbatim — the user needs to + type them on their phone, so do not paraphrase or reformat them. +4. If the output mentions that no public origin is configured, explain the Tailscale Serve setup: + `tailscale serve --bg 127.0.0.1:8791`, then + `node "$GROK_PLUGIN_ROOT/bin/glance" set-origin https://..ts.net`. + +For anything beyond running the command — troubleshooting a failed passkey prompt, explaining the +approval modes, or a first-time setup — read the `glance` skill and follow it. + +Do not change the approval policy unless that is what was asked. Warn before running `set-origin` +on a setup that already has devices enrolled: the RP ID changes and existing passkeys stop working. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..74c6187 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,107 @@ +{ + "_comment": [ + "grok-glance hook wiring.", + "Passive events use type=http: they POST straight into the daemon with no process spawn,", + "so they cost ~nothing per tool call and fail open if the daemon is not running.", + "SessionStart uses type=command because it is what boots the daemon.", + "PreToolUse is wired twice on purpose: an http entry records every tool call for the", + "timeline, and a command entry gates only risky tools, because only a command hook has a", + "documented way to return a deny decision.", + "If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite", + "the URLs below, or edit them by hand." + ], + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"", + "timeout": 20 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "http", + "url": "http://127.0.0.1:8791/hook/record", + "timeout": 3 + } + ] + }, + { + "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$", + "hooks": [ + { + "type": "command", + "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"", + "timeout": 125 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "PostToolUseFailure": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "UserPromptSubmit": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "PermissionDenied": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "Notification": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "Stop": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "StopFailure": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "SubagentStart": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "SubagentStop": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "PreCompact": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "PostCompact": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ], + "SessionEnd": [ + { + "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + } + ] + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f9791f4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2283 @@ +{ + "name": "grok-glance", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "grok-glance", + "version": "0.1.0", + "dependencies": { + "@heroui/react": "3.2.4", + "@heroui/styles": "3.2.4", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.1", + "@types/node": "22.12.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "6.0.2", + "tailwind-variants": "3.3.0", + "tailwindcss": "4.3.1", + "typescript": "5.6.3", + "vite": "8.0.16" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adobe/react-spectrum": { + "version": "3.47.3", + "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.3.tgz", + "integrity": "sha512-tWZG59+xTbXIqeZB4uyuo2qljk3nRK66IkdBmMgbl2p6peApkKbrtJ/YOM8rW4gemdmv7qoJlbIHvy8aJkYsCQ==", + "dependencies": { + "@internationalized/date": "^3.12.3", + "@react-types/shared": "^3.36.1", + "@spectrum-icons/ui": "^3.7.1", + "@spectrum-icons/workflow": "^4.3.1", + "@swc/helpers": "^0.5.0", + "client-only": "^0.0.1", + "clsx": "^2.0.0", + "react-aria": "3.51.0", + "react-aria-components": "1.20.0", + "react-stately": "3.49.0", + "react-transition-group": "^4.4.5", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@adobe/react-spectrum-ui": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-ui/-/react-spectrum-ui-1.2.1.tgz", + "integrity": "sha512-wcrbEE2O/9WnEn6avBnaVRRx88S5PLFsPLr4wffzlbMfXeQsy+RMQwaJd3cbzrn18/j04Isit7f7Emfn0dhrJA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@adobe/react-spectrum-workflow": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-workflow/-/react-spectrum-workflow-2.3.5.tgz", + "integrity": "sha512-b53VIPwPWKb/T5gzE3qs+QlGP5gVrw/LnWV3xMksDU+CRl3rzOKUwxIGiZO8ICyYh1WiyqY4myGlPU/nAynBUg==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "peer": true, + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@heroui/react": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@heroui/react/-/react-3.2.4.tgz", + "integrity": "sha512-ZXCTyz1G7bgCdHVxRPy4vIRI/wdb0ZvzEUJGG7CAEp0zqQsKrTH1o9i3Ns8yM6EidtAyfMosULN29gFA3wegBA==", + "dependencies": { + "@heroui/styles": "3.2.4", + "@radix-ui/react-avatar": "1.1.11", + "@react-stately/utils": "3.12.1", + "@react-types/color": "3.2.0", + "@react-types/shared": "3.36.1", + "input-otp": "1.4.2", + "tailwind-merge": "3.4.0", + "tailwind-variants": "3.3.1" + }, + "peerDependencies": { + "@react-aria/i18n": "^3.13.1", + "@react-aria/ssr": "^3.10.1", + "@react-aria/utils": "^3.34.1", + "react": ">=19.0.0", + "react-aria": "^3.51.0", + "react-aria-components": "^1.20.0", + "react-dom": ">=19.0.0", + "tailwindcss": ">=4.0.0" + } + }, + "node_modules/@heroui/react/node_modules/tailwind-variants": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", + "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", + "engines": { + "node": ">=16.9.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@heroui/styles": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@heroui/styles/-/styles-3.2.4.tgz", + "integrity": "sha512-tjiT7VFUSjw7TA6ZjeT+D3oX62+D27l7dhPwW+emwPnkdkRoKALFJEPBsxpmEI/x18uNkKFIoTk85bICyOO8Vg==", + "dependencies": { + "tailwind-variants": "3.3.1", + "tw-animate-css": "1.4.0" + }, + "peerDependencies": { + "tailwindcss": ">=4.0.0" + } + }, + "node_modules/@heroui/styles/node_modules/tailwind-variants": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", + "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", + "engines": { + "node": ">=16.9.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, + "node_modules/@internationalized/date": { + "version": "3.12.3", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz", + "integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/message": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.10.tgz", + "integrity": "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog==", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0", + "intl-messageformat": "^10.1.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", + "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz", + "integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "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, + "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, + "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, + "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 + }, + "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, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.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 + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "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.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "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-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.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-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "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-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-aria/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@react-aria/color/-/color-3.2.1.tgz", + "integrity": "sha512-v/pZh1yBNeayQ5Kio0xRIX0gBf6uu+KjCRW4QSydTyRpmhvk6sMzHT9i9/vIMdaDHaYHBXlXF+Tp/mvS9CG4Zw==", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "^3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/i18n": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.13.1.tgz", + "integrity": "sha512-z56ZYcbfpNmMyiGLhyEjytpmEfoTlBaksk84q4kds3HvNkf7QWKj+DJVfVDrJX+c1LyuBsszLSX7yxJRiHsYKQ==", + "peer": true, + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/message": "^3.1.9", + "@internationalized/string": "^3.2.8", + "@swc/helpers": "^0.5.0", + "react-aria": "^3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.10.1.tgz", + "integrity": "sha512-jn038/ZYmu6DpfXJ6r2U9zFFppjbc9wnApPJSCxao2RZVEqep4YyoniHSy8qv6V21/xyS4IV7W9a+X2jOjSuag==", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "^3.48.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.34.1.tgz", + "integrity": "sha512-H6+rGZL+0f58bBNaUMfctEnT+NogqwAk+nHiB8sR3K+YlQ37GTuCijy2U/pPvQtFMS5mURrjZeBH5JNNXsx14A==", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "^3.48.0", + "react-stately": "^3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-spectrum/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@react-spectrum/color/-/color-3.2.1.tgz", + "integrity": "sha512-FbB+GaVufsN+obNJmUAe8XGZTeJjEWvEf/k/7nxDxoDCBdg/o6eEPONAZyID5Dj1f8BZKNym8VURxDZx7IIyuQ==", + "dependencies": { + "@adobe/react-spectrum": "^3.47.0", + "@swc/helpers": "^0.5.0", + "react-stately": "^3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-spectrum/provider": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.11.1.tgz", + "integrity": "sha512-TsoNdVdmlQ7L+75ILq5Yb3+wp/I1AtIeat0o+Y+ZBxP+TtWpwT1ZtCB5l3cplFVzHzOpZlzO0VaDrDP9ElGYDw==", + "peer": true, + "dependencies": { + "@adobe/react-spectrum": "^3.47.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/color": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.10.1.tgz", + "integrity": "sha512-v4YlLa/oRQpPa/M9Yq1e0Bl8Z7xzGZdNDAIbfIMZuiJtacaOKriPW+AOgjjT/Ab0fNX2YDbwIYvkiMOJBnTi5A==", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-stately": "^3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.12.1.tgz", + "integrity": "sha512-NqKfzrknpfwiewx7R2vk1P+CneClInPDsIhw15+jOcUYSEfej0nta4cJywuKQJ2gsPwqX/ojDNixedCve9FWGw==", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-stately": "^3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@react-types/color/-/color-3.2.0.tgz", + "integrity": "sha512-beV3vz80nzZ1EuYUM7296Kyi3AHcMrbQw0qub/9yzHWVTKKc5sy/e4dCMKcWL/ArkeAyc7jDOiui190RQ4l0Fw==", + "dependencies": { + "@react-aria/color": "^3.2.0", + "@react-spectrum/color": "^3.2.0", + "@react-stately/color": "^3.10.0" + }, + "peerDependencies": { + "@react-spectrum/provider": "^3.0.0", + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz", + "integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@spectrum-icons/ui": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.1.tgz", + "integrity": "sha512-veQymocUYo5OciXQajSailOdbWe+k6+2ehfF8D4d0V923D4xOUadtT253xXZ5vEQjPat6Kyp2WDKeQNjd7kL1w==", + "dependencies": { + "@adobe/react-spectrum-ui": "1.2.1", + "@babel/runtime": "^7.24.4", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "@adobe/react-spectrum": "^3.47.0", + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@spectrum-icons/workflow": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.1.tgz", + "integrity": "sha512-kDF+/EbFVyLGytotqqdYt4uSij4j/PQmDQO5km/C6DyzKjyuic3FnSBFinR+mA6oFv1OjMcLvrrDBqK3wbqRlA==", + "dependencies": { + "@adobe/react-spectrum-workflow": "2.3.5", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "@adobe/react-spectrum": "^3.47.0", + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "peer": true + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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 + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, + "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, + "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==" + }, + "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, + "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, + "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, + "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, + "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, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "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, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "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 + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-aria": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz", + "integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==", + "dependencies": { + "@internationalized/date": "^3.12.3", + "@internationalized/number": "^3.6.7", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.49.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-aria-components": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz", + "integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==", + "dependencies": { + "@internationalized/date": "^3.12.3", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", + "@swc/helpers": "^0.5.0", + "client-only": "^0.0.1", + "react-aria": "3.51.0", + "react-stately": "3.49.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-stately": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz", + "integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==", + "dependencies": { + "@internationalized/date": "^3.12.3", + "@internationalized/number": "^3.6.7", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "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==" + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.0.tgz", + "integrity": "sha512-t1QsB42dcwUdaCEArO0tRZcP0nbCcAmJKRYCs7jmwYSFOCXlGqgO8c0TrCYm9OranYmT6i9YOi8LFo2gxmxnow==", + "dev": true, + "engines": { + "node": ">=16.9.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==" + }, + "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, + "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", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7112813 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "grok-glance", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Glance at what Grok Build is doing, from your phone, behind a passkey.", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "npm run build:server && npm run build:web", + "build:server": "tsc -p tsconfig.server.json", + "build:web": "tsc -p tsconfig.web.json && vite build", + "dev": "vite", + "start": "node dist/server/index.js", + "glance": "node bin/glance" + }, + "dependencies": { + "@heroui/react": "3.2.4", + "@heroui/styles": "3.2.4", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.1", + "@types/node": "22.12.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "6.0.2", + "tailwind-variants": "3.3.0", + "tailwindcss": "4.3.1", + "typescript": "5.6.3", + "vite": "8.0.16" + } +} diff --git a/server/src/approvals.ts b/server/src/approvals.ts new file mode 100644 index 0000000..b80e80d --- /dev/null +++ b/server/src/approvals.ts @@ -0,0 +1,143 @@ +import crypto from "node:crypto"; +import type { Config } from "./config.js"; +import type { GlanceState, HookPayload } from "./state.js"; +import { summarizeTool } from "./summarize.js"; +import type { PendingApproval } from "./protocol.js"; + +export interface Decision { + decision: "allow" | "deny"; + reason?: string; +} + +interface Waiter { + approval: PendingApproval; + settle: (decision: Decision) => void; + timer: NodeJS.Timeout; +} + +/** + * Holds a PreToolUse hook open while your phone decides. + * + * Every path that is not an explicit tap is designed to get out of the way: approval off, + * tool not risky, nobody watching, or the request timing out all resolve immediately so a + * dashboard can never become the reason your agent stalls. + */ +export class ApprovalBroker { + private readonly waiters = new Map(); + + constructor( + private readonly cfg: Config, + private readonly state: GlanceState, + /** Is at least one browser currently streaming events? */ + private readonly hasWatcher: () => boolean, + ) {} + + private gates(toolName: string): boolean { + const { mode, riskyPattern } = this.cfg.approval; + if (mode === "off") return false; + if (mode === "all") return true; + try { + return new RegExp(riskyPattern).test(toolName); + } catch { + // A bad pattern should not silently gate everything. + return false; + } + } + + pending(): PendingApproval[] { + return [...this.waiters.values()] + .map((w) => w.approval) + .sort((a, b) => a.createdAt - b.createdAt); + } + + async request(payload: HookPayload): Promise { + const tool = typeof payload.toolName === "string" ? payload.toolName : "tool"; + if (!this.gates(tool)) return { decision: "allow" }; + + if (this.cfg.approval.requireWatcher && !this.hasWatcher()) { + return { decision: "allow" }; + } + + const sessionId = payload.sessionId ?? "unknown"; + const summary = summarizeTool(tool, payload.toolInput); + const now = Date.now(); + const approval: PendingApproval = { + id: crypto.randomBytes(9).toString("base64url"), + sessionId, + sessionLabel: this.state.sessionLabel(sessionId), + tool, + title: summary.title, + detail: summary.detail, + createdAt: now, + expiresAt: now + this.cfg.approval.timeoutMs, + }; + + this.state.record(sessionId, "approval_request", `Waiting on you: ${tool}`, { + tool, + detail: summary.title, + }); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.waiters.delete(approval.id); + const onTimeout = this.cfg.approval.onTimeout; + this.state.record( + sessionId, + "approval_expired", + onTimeout === "deny" + ? `No answer in time - denied ${tool}` + : `No answer in time - allowed ${tool}`, + { tool, detail: summary.title }, + ); + resolve( + onTimeout === "deny" + ? { decision: "deny", reason: "grok-glance: no answer from your device in time" } + : { decision: "allow" }, + ); + }, this.cfg.approval.timeoutMs); + + // Do not hold the process open just for a pending approval. + timer.unref?.(); + + this.waiters.set(approval.id, { + approval, + timer, + settle: (decision) => resolve(decision), + }); + }); + } + + /** Called by the API when you tap Approve or Deny. */ + resolve(id: string, decision: "allow" | "deny", by: string): boolean { + const waiter = this.waiters.get(id); + if (!waiter) return false; + clearTimeout(waiter.timer); + this.waiters.delete(id); + + const { approval } = waiter; + this.state.record( + approval.sessionId, + decision === "allow" ? "approval_allowed" : "approval_denied", + decision === "allow" + ? `Approved ${approval.tool} from ${by}` + : `Denied ${approval.tool} from ${by}`, + { tool: approval.tool, detail: approval.title }, + ); + + waiter.settle( + decision === "allow" + ? { decision: "allow" } + : { decision: "deny", reason: `Denied from grok-glance (${by})` }, + ); + return true; + } + + /** Resolve everything as allow — used on shutdown so no hook is left hanging. */ + drain(): void { + for (const [id, waiter] of this.waiters) { + clearTimeout(waiter.timer); + this.waiters.delete(id); + waiter.settle({ decision: "allow" }); + } + } +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..4d2331c --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,173 @@ +import crypto from "node:crypto"; +import type { IncomingMessage } from "node:http"; + +/* ------------------------------------------------------------------- cookies */ + +export const SESSION_COOKIE = "glance_session"; +export const CSRF_HEADER = "x-glance-csrf"; + +export function parseCookies(header: string | undefined): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx < 0) continue; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (key) out[key] = decodeURIComponent(value); + } + return out; +} + +/** token.hmac(token) — lets us reject forged cookies without touching disk. */ +export function signToken(token: string, secret: Buffer): string { + const mac = crypto.createHmac("sha256", secret).update(token).digest("base64url"); + return `${token}.${mac}`; +} + +export function unsignToken(signed: string | undefined, secret: Buffer): string | null { + if (!signed) return null; + const idx = signed.lastIndexOf("."); + if (idx <= 0) return null; + const token = signed.slice(0, idx); + const mac = signed.slice(idx + 1); + const expected = crypto.createHmac("sha256", secret).update(token).digest("base64url"); + const a = Buffer.from(mac); + const b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; + return token; +} + +/** + * The daemon always listens on plain http (a tunnel terminates TLS), so whether the cookie + * may carry the Secure flag depends on how the *browser* reached us. Setting Secure on a + * genuinely-http localhost connection would make the browser throw the cookie away. + */ +export function requestIsHttps(req: IncomingMessage): boolean { + const proto = header(req, "x-forwarded-proto"); + if (proto) return proto.split(",")[0].trim() === "https"; + return false; +} + +export function buildSessionCookie( + value: string, + opts: { secure: boolean; maxAgeSec: number }, +): string { + const parts = [ + `${SESSION_COOKIE}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "SameSite=Strict", + `Max-Age=${opts.maxAgeSec}`, + ]; + if (opts.secure) parts.push("Secure"); + return parts.join("; "); +} + +export function clearSessionCookie(secure: boolean): string { + const parts = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"]; + if (secure) parts.push("Secure"); + return parts.join("; "); +} + +export function header(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + if (Array.isArray(value)) return value[0]; + return value; +} + +/* -------------------------------------------------------------- rate limiting */ + +/** + * Fixed-window counter. Keyed globally rather than per-IP on purpose: behind a tunnel every + * request arrives from 127.0.0.1, so per-IP buckets would be a single bucket wearing a hat. + */ +export class RateLimiter { + private hits = new Map(); + + constructor( + private readonly limit: number, + private readonly windowMs: number, + ) {} + + /** Returns true when the caller is still within budget. */ + allow(key: string): boolean { + const now = Date.now(); + const entry = this.hits.get(key); + if (!entry || entry.resetAt <= now) { + this.hits.set(key, { count: 1, resetAt: now + this.windowMs }); + return true; + } + entry.count += 1; + return entry.count <= this.limit; + } + + reset(key: string): void { + this.hits.delete(key); + } +} + +/* ---------------------------------------------------------- enrolment codes */ + +// No 0/O/1/I/L — these get read off a terminal and typed on a phone. +const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; +const CODE_LENGTH = 8; +const CODE_TTL_MS = 10 * 60_000; +const MAX_CODE_ATTEMPTS = 5; + +interface EnrollmentCode { + code: string; + expiresAt: number; + attempts: number; +} + +export class EnrollmentCodes { + private current: EnrollmentCode | null = null; + + mint(): { code: string; expiresInMs: number } { + const bytes = crypto.randomBytes(CODE_LENGTH); + let code = ""; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]; + } + this.current = { code, expiresAt: Date.now() + CODE_TTL_MS, attempts: 0 }; + return { code, expiresInMs: CODE_TTL_MS }; + } + + /** Constant-time compare. Counts the attempt, and burns the code after too many misses. */ + check(candidate: string): boolean { + const entry = this.current; + if (!entry) return false; + if (entry.expiresAt <= Date.now()) { + this.current = null; + return false; + } + entry.attempts += 1; + if (entry.attempts > MAX_CODE_ATTEMPTS) { + this.current = null; + return false; + } + const a = Buffer.from(normalize(candidate)); + const b = Buffer.from(entry.code); + return a.length === b.length && crypto.timingSafeEqual(a, b); + } + + /** + * Single use. Registration checks the code twice — once to hand out options, once to accept + * the attestation — so only the second call consumes it, otherwise a failed prompt on the + * phone would force you back to the terminal for a fresh code. + */ + consume(candidate: string): boolean { + if (!this.check(candidate)) return false; + this.current = null; + return true; + } + + get active(): boolean { + return !!this.current && this.current.expiresAt > Date.now(); + } +} + +function normalize(code: string): string { + return code.trim().toUpperCase().replace(/[\s-]/g, ""); +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..8bf4193 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,142 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ApprovalSettings } from "./protocol.js"; + +export const VERSION = "0.1.0"; +export const DEFAULT_PORT = 8791; + +export interface Config { + port: number; + host: string; + /** Public https origin the phone will use, e.g. https://box.tailnet-1234.ts.net */ + origin?: string; + /** WebAuthn Relying Party ID. Derived from `origin` unless set explicitly. */ + rpId?: string; + rpName: string; + approval: ApprovalSettings; + /** How many events to keep in memory and hand to the UI. */ + retainEvents: number; +} + +export function glanceHome(): string { + if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME); + return path.join(os.homedir(), ".grok", "glance"); +} + +export const paths = { + get home() { + return glanceHome(); + }, + get config() { + return path.join(glanceHome(), "config.json"); + }, + get credentials() { + return path.join(glanceHome(), "credentials.json"); + }, + get authSessions() { + return path.join(glanceHome(), "auth-sessions.json"); + }, + get secret() { + return path.join(glanceHome(), "secret.key"); + }, + get adminToken() { + return path.join(glanceHome(), "admin.token"); + }, + get events() { + return path.join(glanceHome(), "events.jsonl"); + }, +}; + +const DEFAULTS: Config = { + port: DEFAULT_PORT, + host: "127.0.0.1", + rpName: "grok-glance", + approval: { + // Off by default: installing a dashboard should not silently start gating your tools. + // Flip it on from the phone, or with `glance approval risky`. + mode: "off", + riskyPattern: "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$", + timeoutMs: 90_000, + // If nobody is actually watching, allow immediately rather than stalling the agent. + requireWatcher: true, + // Grok's own hook contract is fail-open on timeout, so match it by default. + onTimeout: "allow", + }, + retainEvents: 400, +}; + +export function ensureHome(): void { + fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 }); + // Tighten it even if the directory already existed with looser bits. + try { + fs.chmodSync(glanceHome(), 0o700); + } catch { + /* best effort */ + } +} + +export function loadConfig(): Config { + ensureHome(); + let stored: Partial = {}; + try { + stored = JSON.parse(fs.readFileSync(paths.config, "utf8")) as Partial; + } catch { + /* first run */ + } + const merged: Config = { + ...DEFAULTS, + ...stored, + approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) }, + }; + if (process.env.GLANCE_PORT) { + const p = Number(process.env.GLANCE_PORT); + if (Number.isFinite(p)) merged.port = p; + } + if (process.env.GLANCE_ORIGIN) merged.origin = process.env.GLANCE_ORIGIN; + merged.rpId = merged.rpId ?? deriveRpId(merged.origin); + return merged; +} + +export function saveConfig(cfg: Config): void { + ensureHome(); + fs.writeFileSync(paths.config, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); +} + +/** + * The RP ID is the origin's hostname. Note that a bare IP address is not a valid RP ID, + * so a LAN address like 192.168.1.20 can never work — that is a WebAuthn rule, not ours. + */ +export function deriveRpId(origin?: string): string | undefined { + if (!origin) return undefined; + try { + const url = new URL(origin); + const host = url.hostname; + if (isIpAddress(host)) return undefined; + return host; + } catch { + return undefined; + } +} + +export function isIpAddress(host: string): boolean { + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; + if (host.includes(":")) return true; // IPv6 + return false; +} + +/** + * Origins we will accept assertions from. The configured public origin, plus localhost so + * that you can enrol and test on the machine itself before setting up a tunnel. + */ +export function expectedOrigins(cfg: Config): string[] { + const list = [`http://localhost:${cfg.port}`, `http://127.0.0.1:${cfg.port}`]; + if (cfg.origin) list.unshift(cfg.origin.replace(/\/$/, "")); + return list; +} + +export function expectedRpIds(cfg: Config): string[] { + const ids = new Set(["localhost"]); + if (cfg.rpId) ids.add(cfg.rpId); + return [...ids]; +} diff --git a/server/src/http.ts b/server/src/http.ts new file mode 100644 index 0000000..55a8f09 --- /dev/null +++ b/server/src/http.ts @@ -0,0 +1,84 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +const MAX_BODY_BYTES = 256 * 1024; + +export interface Res { + json(status: number, body: unknown, headers?: Record): void; + text(status: number, body: string, headers?: Record): void; + empty(status: number, headers?: Record): void; +} + +/** Headers applied to every response. The UI is entirely self-hosted, so the CSP can be strict. */ +export const SECURITY_HEADERS: Record = { + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY", + "cross-origin-opener-policy": "same-origin", + "content-security-policy": [ + "default-src 'self'", + "script-src 'self'", + // HeroUI/Tailwind inject style attributes and inline blocks at runtime. + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join("; "), +}; + +export function responder(res: ServerResponse): Res { + const send = (status: number, body: string | null, headers: Record = {}) => { + if (res.headersSent) return; + res.writeHead(status, { ...SECURITY_HEADERS, ...headers }); + res.end(body ?? undefined); + }; + return { + json(status, body, headers = {}) { + send(status, JSON.stringify(body), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + ...headers, + }); + }, + text(status, body, headers = {}) { + send(status, body, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + ...headers, + }); + }, + empty(status, headers = {}) { + send(status, null, headers); + }, + }; +} + +export async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error("body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +export async function readJson(req: IncomingMessage): Promise { + const raw = await readBody(req); + if (!raw.trim()) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..8b01fcb --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,534 @@ +/** + * grok-glance daemon. + * + * One small http server with three kinds of caller: + * + * /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one. + * /api/* the web app, gated by a passkey-backed cookie session. + * /local/* the `glance` CLI, gated by a rotating admin token on disk. + * + * Everything the hooks touch is written to fail open: if this process is confused, wedged, or + * gone, Grok Build keeps working. + */ + +import http from "node:http"; +import crypto from "node:crypto"; +import { URL } from "node:url"; +import { + DEFAULT_PORT, + VERSION, + deriveRpId, + ensureHome, + expectedOrigins, + isIpAddress, + loadConfig, + paths, + saveConfig, +} from "./config.js"; +import { + CSRF_HEADER, + EnrollmentCodes, + RateLimiter, + SESSION_COOKIE, + buildSessionCookie, + clearSessionCookie, + header, + parseCookies, + requestIsHttps, + signToken, + unsignToken, +} from "./auth.js"; +import { readJson, responder } from "./http.js"; +import { serveStatic, webBuildExists } from "./static.js"; +import { SseHub } from "./sse.js"; +import { GlanceState, type HookPayload } from "./state.js"; +import { ApprovalBroker } from "./approvals.js"; +import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js"; +import { + destroyAuthSession, + deviceList, + lookupAuthSession, + revokeCredentials, + rotateAdminToken, + sessionSecret, +} from "./store.js"; +import type { ApprovalMode, GateInfo } from "./protocol.js"; +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server"; + +ensureHome(); + +const cfg = loadConfig(); +const secret = sessionSecret(); +const adminToken = rotateAdminToken(); +const webauthn = new WebAuthnService(cfg); +const codes = new EnrollmentCodes(); + +// Generous enough for a fumbled passkey prompt, tight enough that the code is not brute-forceable. +const authLimiter = new RateLimiter(40, 5 * 60_000); +const enrollLimiter = new RateLimiter(12, 5 * 60_000); + +const state = new GlanceState(cfg); + +let hub: SseHub | null = null; +const broker = new ApprovalBroker(cfg, state, () => hub?.hasWatcher() ?? false); +const sse = new SseHub(() => state.snapshot(broker.pending())); +hub = sse; + +state.onChange(() => sse.publish()); + +/* ------------------------------------------------------------------ request auth */ + +interface Session { + token: string; + credentialId: string; + label: string; +} + +function currentSession(req: http.IncomingMessage): Session | null { + const cookies = parseCookies(req.headers.cookie); + const token = unsignToken(cookies[SESSION_COOKIE], secret); + if (!token) return null; + const record = lookupAuthSession(token); + if (!record) return null; + return { token, credentialId: record.credentialId, label: record.label }; +} + +function isAdmin(req: http.IncomingMessage): boolean { + const provided = header(req, "x-glance-admin"); + if (!provided) return false; + const a = Buffer.from(provided); + const b = Buffer.from(adminToken); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +/** + * `application/json` is not a CORS-safelisted content type, so requiring it exactly means a + * hostile page cannot post here without a preflight we never answer. The custom header on + * /api/* is a second, independent barrier on top of the SameSite=Strict cookie. + */ +function isJsonPost(req: http.IncomingMessage): boolean { + const ct = (header(req, "content-type") ?? "").split(";")[0].trim().toLowerCase(); + return ct === "application/json"; +} + +function hasCsrfHeader(req: http.IncomingMessage): boolean { + return !!header(req, CSRF_HEADER); +} + +/* ---------------------------------------------------------------------- routing */ + +const server = http.createServer((req, res) => { + handle(req, res).catch((err) => { + console.error("[glance] unhandled", err); + try { + responder(res).json(500, { error: "internal error" }); + } catch { + /* response already gone */ + } + }); +}); + +async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const out = responder(res); + const url = new URL(req.url ?? "/", `http://localhost:${cfg.port}`); + const p = url.pathname; + const method = req.method ?? "GET"; + + if (method === "OPTIONS") { + // No CORS. Cross-origin callers get nothing, which is the point. + out.empty(405, { allow: "GET, POST" }); + return; + } + + /* ---------------------------------------------------------------- health */ + + if (p === "/healthz") { + out.json(200, { ok: true, version: VERSION }); + return; + } + + /* ----------------------------------------------------------------- hooks */ + + if (p.startsWith("/hook/")) { + if (method !== "POST" || !isJsonPost(req)) { + out.json(405, { error: "post json" }); + return; + } + const payload = ((await readJson(req)) ?? {}) as HookPayload; + + if (p === "/hook/record") { + const event = state.ingest(payload); + out.json(200, { ok: true, id: event?.id ?? null }); + return; + } + + if (p === "/hook/approve") { + // Note: this deliberately does not ingest an event. The PreToolUse http hook already + // recorded the tool call; recording it here too would double every entry. + const decision = await broker.request(payload); + out.json(200, decision); + return; + } + + out.json(404, { error: "unknown hook" }); + return; + } + + /* -------------------------------------------------------- local admin API */ + + if (p.startsWith("/local/")) { + if (!isAdmin(req)) { + out.json(403, { error: "admin token required" }); + return; + } + + if (p === "/local/status" && method === "GET") { + out.json(200, { + version: VERSION, + origin: cfg.origin, + rpId: cfg.rpId, + devices: deviceList().length, + approval: cfg.approval, + watchers: sse.count, + sessions: state.sessionCount, + events: state.eventCount, + webBuilt: webBuildExists(), + home: paths.home, + }); + return; + } + + if (p === "/local/enroll" && method === "POST") { + const minted = codes.mint(); + const base = cfg.origin?.replace(/\/$/, "") ?? `http://localhost:${cfg.port}`; + out.json(200, { + code: minted.code, + expiresInMs: minted.expiresInMs, + url: `${base}/?enroll=1`, + originConfigured: !!cfg.origin, + }); + return; + } + + if (p === "/local/origin" && method === "POST") { + const body = await readJson<{ origin?: string }>(req); + const raw = (body?.origin ?? "").trim().replace(/\/$/, ""); + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + out.json(400, { error: "not a url" }); + return; + } + if (isIpAddress(parsed.hostname)) { + out.json(400, { + error: + "a bare IP address cannot be a WebAuthn relying party id - use a hostname with TLS", + }); + return; + } + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") { + out.json(400, { error: "passkeys need https (or localhost for local testing)" }); + return; + } + cfg.origin = `${parsed.protocol}//${parsed.host}`; + cfg.rpId = deriveRpId(cfg.origin); + saveConfig(cfg); + out.json(200, { origin: cfg.origin, rpId: cfg.rpId }); + return; + } + + if (p === "/local/devices" && method === "GET") { + out.json(200, { devices: deviceList() }); + return; + } + + if (p === "/local/devices/revoke" && method === "POST") { + const body = await readJson<{ idPrefix?: string }>(req); + const prefix = (body?.idPrefix ?? "").trim(); + if (prefix.length < 4) { + out.json(400, { error: "give at least 4 characters of the device id" }); + return; + } + out.json(200, { revoked: revokeCredentials(prefix) }); + sse.publish(); + return; + } + + if (p === "/local/approval" && method === "POST") { + const body = await readJson<{ mode?: ApprovalMode }>(req); + if (!applyApprovalMode(body?.mode)) { + out.json(400, { error: "mode must be off, risky or all" }); + return; + } + out.json(200, cfg.approval); + return; + } + + if (p === "/local/shutdown" && method === "POST") { + out.json(200, { ok: true }); + shutdown("cli"); + return; + } + + out.json(404, { error: "unknown local endpoint" }); + return; + } + + /* -------------------------------------------------------------- web API */ + + if (p.startsWith("/api/")) { + if (method === "POST" && (!isJsonPost(req) || !hasCsrfHeader(req))) { + out.json(400, { error: "bad request" }); + return; + } + + const session = currentSession(req); + const secure = requestIsHttps(req); + + if (p === "/api/gate" && method === "GET") { + const gate: GateInfo = { + authenticated: !!session, + enrolled: webauthn.enrolled, + enrollmentOpen: codes.active, + version: VERSION, + deviceLabel: session?.label, + rpId: cfg.rpId, + }; + out.json(200, gate); + return; + } + + /* --- enrolment: a one-time code from the terminal, or an already-trusted device --- */ + + if (p === "/api/auth/register/options" && method === "POST") { + if (!enrollLimiter.allow("enroll")) { + out.json(429, { error: "too many attempts, wait a few minutes" }); + return; + } + const body = await readJson<{ code?: string }>(req); + if (!session && !codes.check(body?.code ?? "")) { + out.json(403, { error: "that enrolment code is not valid" }); + return; + } + out.json(200, await webauthn.registrationOptions()); + return; + } + + if (p === "/api/auth/register/verify" && method === "POST") { + if (!enrollLimiter.allow("enroll")) { + out.json(429, { error: "too many attempts, wait a few minutes" }); + return; + } + const body = await readJson<{ + code?: string; + label?: string; + response?: RegistrationResponseJSON; + }>(req); + if (!body?.response) { + out.json(400, { error: "missing response" }); + return; + } + if (!session && !codes.consume(body.code ?? "")) { + out.json(403, { error: "that enrolment code is not valid" }); + return; + } + const label = body.label?.trim() || "phone"; + const result = await webauthn.verifyRegistration(body.response, label); + if (!result.ok || !result.token) { + out.json(400, { error: result.error ?? "registration failed" }); + return; + } + enrollLimiter.reset("enroll"); + out.json( + 200, + { ok: true, label }, + { + "set-cookie": buildSessionCookie(signToken(result.token, secret), { + secure, + maxAgeSec: Math.floor(SESSION_TTL_MS / 1000), + }), + }, + ); + console.log(`[glance] enrolled device "${label}"`); + return; + } + + /* --------------------------------- sign in ---------------------------------- */ + + if (p === "/api/auth/login/options" && method === "POST") { + if (!authLimiter.allow("login")) { + out.json(429, { error: "too many attempts, wait a few minutes" }); + return; + } + if (!webauthn.enrolled) { + out.json(409, { error: "no device enrolled yet - run `glance enroll`" }); + return; + } + out.json(200, await webauthn.authenticationOptions()); + return; + } + + if (p === "/api/auth/login/verify" && method === "POST") { + if (!authLimiter.allow("login")) { + out.json(429, { error: "too many attempts, wait a few minutes" }); + return; + } + const body = await readJson<{ response?: AuthenticationResponseJSON }>(req); + if (!body?.response) { + out.json(400, { error: "missing response" }); + return; + } + const result = await webauthn.verifyAuthentication(body.response); + if (!result.ok || !result.token) { + out.json(403, { error: result.error ?? "sign in failed" }); + return; + } + authLimiter.reset("login"); + out.json( + 200, + { ok: true, label: result.label }, + { + "set-cookie": buildSessionCookie(signToken(result.token, secret), { + secure, + maxAgeSec: Math.floor(SESSION_TTL_MS / 1000), + }), + }, + ); + return; + } + + if (p === "/api/auth/logout" && method === "POST") { + if (session) destroyAuthSession(session.token); + out.json(200, { ok: true }, { "set-cookie": clearSessionCookie(secure) }); + return; + } + + /* ------------------------- everything below needs a passkey ------------------ */ + + if (!session) { + out.json(401, { error: "not signed in" }); + return; + } + + if (p === "/api/snapshot" && method === "GET") { + out.json(200, state.snapshot(broker.pending())); + return; + } + + if (p === "/api/approvals/resolve" && method === "POST") { + const body = await readJson<{ id?: string; decision?: "allow" | "deny" }>(req); + const id = body?.id ?? ""; + const decision = body?.decision; + if (!id || (decision !== "allow" && decision !== "deny")) { + out.json(400, { error: "need id and decision" }); + return; + } + const settled = broker.resolve(id, decision, session.label); + // A miss is normal: the prompt may have timed out, or another device answered first. + out.json(settled ? 200 : 409, settled ? { ok: true } : { error: "no longer pending" }); + sse.publish(); + return; + } + + if (p === "/api/approval" && method === "POST") { + const body = await readJson<{ + mode?: ApprovalMode; + requireWatcher?: boolean; + onTimeout?: "allow" | "deny"; + }>(req); + if (body?.mode !== undefined && !applyApprovalMode(body.mode)) { + out.json(400, { error: "mode must be off, risky or all" }); + return; + } + if (typeof body?.requireWatcher === "boolean") { + cfg.approval.requireWatcher = body.requireWatcher; + } + if (body?.onTimeout === "allow" || body?.onTimeout === "deny") { + cfg.approval.onTimeout = body.onTimeout; + } + saveConfig(cfg); + out.json(200, cfg.approval); + sse.publish(); + return; + } + + if (p === "/api/devices" && method === "GET") { + out.json(200, { devices: deviceList(), current: session.credentialId }); + return; + } + + out.json(404, { error: "unknown endpoint" }); + return; + } + + /* ---------------------------------------------------------------- SSE stream */ + + if (p === "/events") { + if (method !== "GET") { + out.json(405, { error: "get only" }); + return; + } + if (!currentSession(req)) { + out.json(401, { error: "not signed in" }); + return; + } + sse.add(res); + return; + } + + /* ------------------------------------------------------------- static files */ + + if (method !== "GET") { + out.json(405, { error: "get only" }); + return; + } + serveStatic(p, res); +} + +/** Shared by the CLI and the web UI so both paths validate the same way. */ +function applyApprovalMode(mode: unknown): boolean { + if (mode !== "off" && mode !== "risky" && mode !== "all") return false; + cfg.approval.mode = mode; + saveConfig(cfg); + sse.publish(); + return true; +} + +/* --------------------------------------------------------------------- lifecycle */ + +let shuttingDown = false; +function shutdown(why: string): void { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[glance] shutting down (${why})`); + // Anything still waiting on a decision gets allowed, so no hook is left hanging. + broker.drain(); + sse.closeAll(); + server.close(() => process.exit(0)); + // Don't let a lingering keep-alive socket hold the process forever. + setTimeout(() => process.exit(0), 1500).unref(); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("uncaughtException", (err) => { + console.error("[glance] uncaught", err); +}); +process.on("unhandledRejection", (err) => { + console.error("[glance] unhandled rejection", err); +}); + +server.listen(cfg.port, cfg.host, () => { + console.log(`[glance] ${VERSION} listening on http://${cfg.host}:${cfg.port}`); + console.log(`[glance] state: ${paths.home}`); + console.log(`[glance] origin: ${cfg.origin ?? "(none set - see README)"} rpId: ${cfg.rpId ?? "localhost"}`); + console.log(`[glance] accepts assertions from: ${expectedOrigins(cfg).join(", ")}`); + if (!webBuildExists()) console.log("[glance] web app not built yet: npm install && npm run build"); + if (cfg.port !== DEFAULT_PORT) console.log(`[glance] note: non-default port, run \`glance sync-hooks\``); +}); + +server.on("error", (err) => { + console.error(`[glance] listen failed: ${(err as Error).message}`); + process.exit(1); +}); diff --git a/server/src/protocol.ts b/server/src/protocol.ts new file mode 100644 index 0000000..1ac7123 --- /dev/null +++ b/server/src/protocol.ts @@ -0,0 +1,107 @@ +/** + * Wire protocol shared between the daemon and the web app. + * + * NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are + * duplicated rather than shared because the server compiles under NodeNext while the web + * app compiles under a bundler resolution, and a single rootDir cannot span both. + */ + +export type EventKind = + | "session_start" + | "session_end" + | "prompt" + | "tool_start" + | "tool_end" + | "tool_fail" + | "permission_denied" + | "turn_end" + | "turn_error" + | "notification" + | "subagent_start" + | "subagent_end" + | "compact" + | "approval_request" + | "approval_allowed" + | "approval_denied" + | "approval_expired"; + +export type SessionState = "working" | "idle" | "waiting" | "error" | "ended"; + +export interface GlanceEvent { + id: number; + ts: number; + sessionId: string; + kind: EventKind; + /** Tool name, for tool-shaped events. */ + tool?: string; + /** One-line human summary, already truncated and redacted. */ + title: string; + /** Optional second line, e.g. a file path or an error message. */ + detail?: string; + durationMs?: number; +} + +export interface SessionView { + id: string; + /** Basename of the workspace root — what you actually recognise on a phone. */ + label: string; + cwd: string; + state: SessionState; + startedAt: number; + lastActivity: number; + lastPrompt?: string; + currentTool?: { name: string; title: string; startedAt: number }; + counts: { tools: number; failures: number; denials: number }; +} + +export interface PendingApproval { + id: string; + sessionId: string; + sessionLabel: string; + tool: string; + title: string; + detail?: string; + createdAt: number; + expiresAt: number; +} + +export type ApprovalMode = "off" | "risky" | "all"; + +export interface ApprovalSettings { + mode: ApprovalMode; + riskyPattern: string; + timeoutMs: number; + /** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */ + requireWatcher: boolean; + /** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */ + onTimeout: "allow" | "deny"; +} + +export interface Snapshot { + now: number; + version: string; + sessions: SessionView[]; + events: GlanceEvent[]; + pending: PendingApproval[]; + approval: ApprovalSettings; +} + +export interface DeviceInfo { + id: string; + label: string; + createdAt: number; + lastUsedAt?: number; +} + +/** Everything the app needs before it knows whether you are signed in. */ +export interface GateInfo { + authenticated: boolean; + /** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */ + enrolled: boolean; + /** True while a one-time enrolment code minted by `glance enroll` is still valid. */ + enrollmentOpen: boolean; + version: string; + deviceLabel?: string; + /** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */ + rpId?: string; +} diff --git a/server/src/sse.ts b/server/src/sse.ts new file mode 100644 index 0000000..b74c6cb --- /dev/null +++ b/server/src/sse.ts @@ -0,0 +1,120 @@ +import type { ServerResponse } from "node:http"; +import { SECURITY_HEADERS } from "./http.js"; +import type { Snapshot } from "./protocol.js"; + +/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */ +const THROTTLE_MS = 250; +/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */ +const HEARTBEAT_MS = 25_000; + +interface Client { + id: number; + res: ServerResponse; +} + +export class SseHub { + private clients = new Map(); + private nextId = 1; + private pending = false; + private lastSentAt = 0; + private timer: NodeJS.Timeout | null = null; + private heartbeat: NodeJS.Timeout | null = null; + + constructor(private readonly snapshot: () => Snapshot) {} + + /** True when at least one browser is listening — the approval broker asks before gating. */ + hasWatcher(): boolean { + return this.clients.size > 0; + } + + get count(): number { + return this.clients.size; + } + + add(res: ServerResponse): void { + res.writeHead(200, { + ...SECURITY_HEADERS, + "content-type": "text/event-stream", + "cache-control": "no-store, no-transform", + connection: "keep-alive", + // Belt and braces for any buffering proxy in front of us. + "x-accel-buffering": "no", + }); + res.write(": connected\n\n"); + + const client: Client = { id: this.nextId++, res }; + this.clients.set(client.id, client); + + const drop = () => { + this.clients.delete(client.id); + if (this.clients.size === 0) this.stopHeartbeat(); + }; + res.on("close", drop); + res.on("error", drop); + + this.send(client, "snapshot", this.snapshot()); + this.startHeartbeat(); + } + + private startHeartbeat(): void { + if (this.heartbeat) return; + this.heartbeat = setInterval(() => { + for (const client of this.clients.values()) { + try { + client.res.write(": ping\n\n"); + } catch { + this.clients.delete(client.id); + } + } + }, HEARTBEAT_MS); + this.heartbeat.unref?.(); + } + + private stopHeartbeat(): void { + if (!this.heartbeat) return; + clearInterval(this.heartbeat); + this.heartbeat = null; + } + + private send(client: Client, event: string, data: unknown): void { + try { + client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } catch { + this.clients.delete(client.id); + } + } + + /** + * Push a fresh full snapshot, throttled. Sending the whole state rather than deltas keeps + * the client dumb: a phone that slept through twenty events still lands on the truth. + */ + publish(): void { + if (this.clients.size === 0) return; + if (this.pending) return; + const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt)); + this.pending = true; + this.timer = setTimeout(() => { + this.pending = false; + this.lastSentAt = Date.now(); + const snap = this.snapshot(); + for (const client of [...this.clients.values()]) { + this.send(client, "snapshot", snap); + } + }, wait); + this.timer.unref?.(); + } + + closeAll(): void { + if (this.timer) clearTimeout(this.timer); + this.stopHeartbeat(); + for (const client of this.clients.values()) { + try { + client.res.write("event: bye\ndata: {}\n\n"); + client.res.end(); + } catch { + /* going away anyway */ + } + } + this.clients.clear(); + } +} diff --git a/server/src/state.ts b/server/src/state.ts new file mode 100644 index 0000000..7885e0a --- /dev/null +++ b/server/src/state.ts @@ -0,0 +1,297 @@ +import { VERSION, type Config } from "./config.js"; +import { appendEventLog, readRecentEvents } from "./store.js"; +import { + labelForWorkspace, + summarizeNotification, + summarizePrompt, + summarizeTool, + truncateDetail, + truncateTitle, +} from "./summarize.js"; +import type { + EventKind, + GlanceEvent, + PendingApproval, + SessionState, + SessionView, + Snapshot, +} from "./protocol.js"; + +/** A session that has said nothing for this long is treated as idle, not working. */ +const STALE_WORKING_MS = 10 * 60_000; + +const EVENT_KIND_BY_HOOK: Record = { + SessionStart: "session_start", + SessionEnd: "session_end", + UserPromptSubmit: "prompt", + PreToolUse: "tool_start", + PostToolUse: "tool_end", + PostToolUseFailure: "tool_fail", + PermissionDenied: "permission_denied", + Stop: "turn_end", + StopFailure: "turn_error", + Notification: "notification", + SubagentStart: "subagent_start", + SubagentStop: "subagent_end", + PreCompact: "compact", + PostCompact: "compact", +}; + +export interface HookPayload { + hookEventName?: string; + sessionId?: string; + cwd?: string; + workspaceRoot?: string; + toolName?: string; + toolInput?: unknown; + [key: string]: unknown; +} + +export class GlanceState { + private events: GlanceEvent[] = []; + private sessions = new Map(); + /** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */ + private toolStarts = new Map(); + private nextId = 1; + private readonly listeners = new Set<() => void>(); + + constructor(private readonly cfg: Config) { + // Warm start: keep recent history across daemon restarts. + const recent = readRecentEvents(cfg.retainEvents); + this.events = recent; + this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1; + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + for (const listener of this.listeners) { + try { + listener(); + } catch { + /* a broken listener must not break ingestion */ + } + } + } + + private session(id: string, payload: HookPayload): SessionView { + let existing = this.sessions.get(id); + if (!existing) { + existing = { + id, + label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""), + cwd: payload.workspaceRoot ?? payload.cwd ?? "", + state: "idle", + startedAt: Date.now(), + lastActivity: Date.now(), + counts: { tools: 0, failures: 0, denials: 0 }, + }; + this.sessions.set(id, existing); + } else if (payload.workspaceRoot || payload.cwd) { + // Keep the label fresh if the session moved. + existing.label = labelForWorkspace(payload.workspaceRoot, payload.cwd ?? existing.cwd); + existing.cwd = payload.workspaceRoot ?? payload.cwd ?? existing.cwd; + } + return existing; + } + + private push(event: GlanceEvent): void { + this.events.push(event); + if (this.events.length > this.cfg.retainEvents) { + this.events.splice(0, this.events.length - this.cfg.retainEvents); + } + appendEventLog(event); + } + + /** Record a raw hook payload. Returns the event it produced, if any. */ + ingest(payload: HookPayload): GlanceEvent | null { + const hookName = payload.hookEventName ?? ""; + const kind = EVENT_KIND_BY_HOOK[hookName]; + if (!kind) return null; + + const sessionId = payload.sessionId ?? "unknown"; + const session = this.session(sessionId, payload); + const now = Date.now(); + session.lastActivity = now; + + const tool = typeof payload.toolName === "string" ? payload.toolName : undefined; + let title = hookName; + let detail: string | undefined; + let durationMs: number | undefined; + + switch (kind) { + case "session_start": + session.state = "idle"; + title = `Session started in ${session.label}`; + detail = session.cwd || undefined; + break; + + case "session_end": + session.state = "ended"; + session.currentTool = undefined; + title = "Session ended"; + break; + + case "prompt": + session.state = "working"; + session.lastPrompt = summarizePrompt(payload); + title = session.lastPrompt; + break; + + case "tool_start": { + const summary = summarizeTool(tool ?? "tool", payload.toolInput); + session.state = "working"; + session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now }; + this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now); + title = summary.title; + detail = summary.detail; + break; + } + + case "tool_end": + case "tool_fail": { + const summary = summarizeTool(tool ?? "tool", payload.toolInput); + const key = `${sessionId}|${tool ?? "tool"}`; + const startedAt = this.toolStarts.get(key); + if (startedAt) { + durationMs = now - startedAt; + this.toolStarts.delete(key); + } + if (session.currentTool?.name === tool) session.currentTool = undefined; + session.state = "working"; + title = summary.title; + detail = summary.detail; + if (kind === "tool_end") { + session.counts.tools += 1; + } else { + session.counts.failures += 1; + detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || detail; + } + break; + } + + case "permission_denied": + session.counts.denials += 1; + title = tool ? `Permission denied: ${tool}` : "Permission denied"; + detail = summarizeTool(tool ?? "tool", payload.toolInput).title; + break; + + case "turn_end": + session.state = "idle"; + session.currentTool = undefined; + title = "Turn finished"; + break; + + case "turn_error": + session.state = "error"; + session.currentTool = undefined; + title = "Turn failed"; + detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined; + break; + + case "notification": + title = summarizeNotification(payload); + break; + + case "subagent_start": + title = "Subagent started"; + detail = truncateDetail(String(payload["description"] ?? payload["subagentType"] ?? "")) || undefined; + break; + + case "subagent_end": + title = "Subagent finished"; + break; + + case "compact": + title = hookName === "PreCompact" ? "Compacting conversation" : "Compaction done"; + break; + + default: + break; + } + + const event: GlanceEvent = { + id: this.nextId++, + ts: now, + sessionId, + kind, + tool, + title: truncateTitle(title), + detail, + durationMs, + }; + this.push(event); + this.notify(); + return event; + } + + /** Record something the daemon itself decided, e.g. an approval outcome. */ + record( + sessionId: string, + kind: EventKind, + title: string, + opts: { tool?: string; detail?: string } = {}, + ): GlanceEvent { + const now = Date.now(); + const session = this.sessions.get(sessionId); + if (session) { + session.lastActivity = now; + if (kind === "approval_request") session.state = "waiting"; + else if (kind === "approval_allowed" || kind === "approval_denied") session.state = "working"; + } + const event: GlanceEvent = { + id: this.nextId++, + ts: now, + sessionId, + kind, + tool: opts.tool, + title: truncateTitle(title), + detail: opts.detail, + }; + this.push(event); + this.notify(); + return event; + } + + private effectiveState(session: SessionView, now: number): SessionState { + if (session.state === "working" && now - session.lastActivity > STALE_WORKING_MS) { + return "idle"; + } + return session.state; + } + + snapshot(pending: PendingApproval[]): Snapshot { + const now = Date.now(); + const waiting = new Set(pending.map((p) => p.sessionId)); + const sessions = [...this.sessions.values()] + .map((s) => ({ + ...s, + state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now), + })) + .sort((a, b) => b.lastActivity - a.lastActivity); + + return { + now, + version: VERSION, + sessions, + events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id), + pending, + approval: this.cfg.approval, + }; + } + + sessionLabel(sessionId: string): string { + return this.sessions.get(sessionId)?.label ?? "workspace"; + } + + get sessionCount(): number { + return this.sessions.size; + } + + get eventCount(): number { + return this.events.length; + } +} diff --git a/server/src/static.ts b/server/src/static.ts new file mode 100644 index 0000000..8f2c8d9 --- /dev/null +++ b/server/src/static.ts @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ServerResponse } from "node:http"; +import { SECURITY_HEADERS } from "./http.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +/** dist/server/* and dist/web/* are siblings after a build. */ +export const WEB_ROOT = path.resolve(here, "..", "web"); + +const TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff2": "font/woff2", +}; + +export function webBuildExists(): boolean { + return fs.existsSync(path.join(WEB_ROOT, "index.html")); +} + +/** + * Serve the built app. Vite fingerprints everything under /assets, so those can be cached hard + * while index.html must not be — otherwise a phone keeps a stale shell after an upgrade. + */ +export function serveStatic(urlPath: string, res: ServerResponse): void { + if (!webBuildExists()) { + res.writeHead(503, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" }); + res.end("grok-glance: web app not built yet. Run `npm install && npm run build`.\n"); + return; + } + + const clean = decodeURIComponent(urlPath.split("?")[0]); + const candidate = path.resolve(WEB_ROOT, "." + path.posix.normalize(clean)); + + // Anything that escapes the build directory falls back to the shell rather than leaking. + const inside = candidate === WEB_ROOT || candidate.startsWith(WEB_ROOT + path.sep); + let file = inside && isFile(candidate) ? candidate : ""; + if (!file) file = path.join(WEB_ROOT, "index.html"); + + const ext = path.extname(file).toLowerCase(); + const isHashed = file.includes(`${path.sep}assets${path.sep}`); + try { + const body = fs.readFileSync(file); + res.writeHead(200, { + ...SECURITY_HEADERS, + "content-type": TYPES[ext] ?? "application/octet-stream", + "cache-control": isHashed ? "public, max-age=31536000, immutable" : "no-cache", + }); + res.end(body); + } catch { + res.writeHead(404, { ...SECURITY_HEADERS, "content-type": "text/plain; charset=utf-8" }); + res.end("not found\n"); + } +} + +function isFile(p: string): boolean { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} diff --git a/server/src/store.ts b/server/src/store.ts new file mode 100644 index 0000000..995e495 --- /dev/null +++ b/server/src/store.ts @@ -0,0 +1,211 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; +import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"; +import { ensureHome, paths } from "./config.js"; +import type { DeviceInfo, GlanceEvent } from "./protocol.js"; + +export interface StoredCredential { + /** Base64URL credential ID. */ + id: string; + /** Base64 (standard) encoded COSE public key. */ + publicKey: string; + counter: number; + transports?: AuthenticatorTransportFuture[]; + label: string; + createdAt: number; + lastUsedAt?: number; + deviceType?: string; + backedUp?: boolean; +} + +interface AuthSessionRecord { + /** SHA-256 of the session token. The token itself is never written to disk. */ + tokenHash: string; + credentialId: string; + label: string; + createdAt: number; + expiresAt: number; +} + +function readJsonFile(file: string, fallback: T): T { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; + } catch { + return fallback; + } +} + +function writeJsonFile(file: string, value: unknown): void { + ensureHome(); + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +/* ------------------------------------------------------------------ secrets */ + +/** HMAC key used to sign session cookies. Created once, 0600. */ +export function sessionSecret(): Buffer { + ensureHome(); + try { + const existing = fs.readFileSync(paths.secret); + if (existing.length >= 32) return existing; + } catch { + /* create below */ + } + const key = crypto.randomBytes(32); + fs.writeFileSync(paths.secret, key, { mode: 0o600 }); + return key; +} + +/** + * Token that authorises privileged local operations (enrol, revoke, shutdown). + * Rotated on every daemon start so a leaked token dies with the process. + */ +export function rotateAdminToken(): string { + ensureHome(); + const token = crypto.randomBytes(24).toString("base64url"); + fs.writeFileSync(paths.adminToken, token + "\n", { mode: 0o600 }); + return token; +} + +/* -------------------------------------------------------------- credentials */ + +export function listCredentials(): StoredCredential[] { + return readJsonFile(paths.credentials, []); +} + +export function saveCredentials(creds: StoredCredential[]): void { + writeJsonFile(paths.credentials, creds); +} + +export function addCredential(cred: StoredCredential): void { + const all = listCredentials().filter((c) => c.id !== cred.id); + all.push(cred); + saveCredentials(all); +} + +export function findCredential(id: string): StoredCredential | undefined { + return listCredentials().find((c) => c.id === id); +} + +export function touchCredential(id: string, counter: number): void { + const all = listCredentials(); + const cred = all.find((c) => c.id === id); + if (!cred) return; + cred.counter = counter; + cred.lastUsedAt = Date.now(); + saveCredentials(all); +} + +export function revokeCredentials(idPrefix: string): number { + const all = listCredentials(); + const keep = all.filter((c) => !c.id.startsWith(idPrefix)); + saveCredentials(keep); + const removed = all.length - keep.length; + if (removed > 0) { + // A revoked device must lose any live session too, or it keeps its cookie access. + const sessions = listAuthSessions().filter((s) => !s.credentialId.startsWith(idPrefix)); + writeJsonFile(paths.authSessions, sessions); + } + return removed; +} + +export function deviceList(): DeviceInfo[] { + return listCredentials().map((c) => ({ + id: c.id, + label: c.label, + createdAt: c.createdAt, + lastUsedAt: c.lastUsedAt, + })); +} + +/* ------------------------------------------------------------ auth sessions */ + +function listAuthSessions(): AuthSessionRecord[] { + const now = Date.now(); + return readJsonFile(paths.authSessions, []).filter( + (s) => s.expiresAt > now, + ); +} + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export function createAuthSession( + credentialId: string, + label: string, + ttlMs: number, +): { token: string; expiresAt: number } { + const token = crypto.randomBytes(32).toString("base64url"); + const expiresAt = Date.now() + ttlMs; + const sessions = listAuthSessions(); + sessions.push({ + tokenHash: hashToken(token), + credentialId, + label, + createdAt: Date.now(), + expiresAt, + }); + writeJsonFile(paths.authSessions, sessions); + return { token, expiresAt }; +} + +export function lookupAuthSession(token: string): AuthSessionRecord | undefined { + const wanted = hashToken(token); + return listAuthSessions().find((s) => { + const a = Buffer.from(s.tokenHash, "hex"); + const b = Buffer.from(wanted, "hex"); + return a.length === b.length && crypto.timingSafeEqual(a, b); + }); +} + +export function destroyAuthSession(token: string): void { + const wanted = hashToken(token); + writeJsonFile( + paths.authSessions, + listAuthSessions().filter((s) => s.tokenHash !== wanted), + ); +} + +/* -------------------------------------------------------------- event log */ + +const MAX_LOG_BYTES = 5 * 1024 * 1024; + +export function appendEventLog(event: GlanceEvent): void { + try { + ensureHome(); + let size = 0; + try { + size = fs.statSync(paths.events).size; + } catch { + /* no log yet */ + } + if (size > MAX_LOG_BYTES) { + fs.renameSync(paths.events, `${paths.events}.1`); + } + fs.appendFileSync(paths.events, JSON.stringify(event) + "\n", { mode: 0o600 }); + } catch { + // The dashboard is not worth crashing over. + } +} + +/** Read back the tail of the log so a restarted daemon still has recent history. */ +export function readRecentEvents(limit: number): GlanceEvent[] { + try { + const text = fs.readFileSync(paths.events, "utf8"); + const lines = text.split("\n").filter(Boolean).slice(-limit); + const out: GlanceEvent[] = []; + for (const line of lines) { + try { + out.push(JSON.parse(line) as GlanceEvent); + } catch { + /* skip malformed line */ + } + } + return out; + } catch { + return []; + } +} diff --git a/server/src/summarize.ts b/server/src/summarize.ts new file mode 100644 index 0000000..d98fcb2 --- /dev/null +++ b/server/src/summarize.ts @@ -0,0 +1,150 @@ +/** + * Turns a raw hook payload into something you can read on a phone screen. + * + * Two jobs: pick the one field that actually says what the tool is doing, and strip anything + * that looks like a credential before it leaves the machine. + */ + +const TITLE_MAX = 180; +const DETAIL_MAX = 400; + +/** + * Conservative redaction: only well-known credential shapes and explicit key=value + * assignments. Deliberately not "redact any long string" — that would mangle ordinary + * paths and hashes and make the timeline useless. + */ +const REDACTIONS: Array<[RegExp, string]> = [ + [/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, "$1-[redacted]"], + [/\bxai-[A-Za-z0-9_-]{16,}/g, "xai-[redacted]"], + [/\bgh[pousr]_[A-Za-z0-9_]{16,}/g, "gh?_[redacted]"], + [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox?-[redacted]"], + [/\bAKIA[0-9A-Z]{16}\b/g, "AKIA[redacted]"], + [/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g, "[jwt redacted]"], + [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[private key redacted]"], + [/\b(authorization|bearer)\s*[:=]?\s*[A-Za-z0-9._~+/-]{16,}=*/gi, "$1 [redacted]"], + [ + /\b([A-Za-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\s*[:=]\s*("[^"]*"|'[^']*'|\S+)/gi, + "$1=[redacted]", + ], +]; + +export function redact(text: string): string { + let out = text; + for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement); + return out; +} + +function clean(value: unknown, max: number): string { + if (value === undefined || value === null) return ""; + const raw = typeof value === "string" ? value : JSON.stringify(value); + const collapsed = redact(raw).replace(/\s+/g, " ").trim(); + return collapsed.length > max ? collapsed.slice(0, max - 1) + "…" : collapsed; +} + +function basename(p: string): string { + const parts = p.replace(/\/+$/, "").split("/"); + return parts[parts.length - 1] || p; +} + +/** Shorten a path to something that fits a phone: keep the last two segments. */ +export function shortPath(p: string): string { + if (!p) return ""; + const parts = p.replace(/\/+$/, "").split("/").filter(Boolean); + if (parts.length <= 2) return p; + return `…/${parts.slice(-2).join("/")}`; +} + +export interface ToolSummary { + title: string; + detail?: string; +} + +/** + * Grok maps Claude Code tool names onto its own, so the input keys follow the Claude + * shapes. Anything unrecognised falls back to a JSON preview. + */ +export function summarizeTool(toolName: string, input: unknown): ToolSummary { + const obj = (typeof input === "object" && input !== null ? input : {}) as Record; + + const pick = (...keys: string[]): string | undefined => { + for (const key of keys) { + const value = obj[key]; + if (typeof value === "string" && value.trim()) return value; + } + return undefined; + }; + + switch (toolName) { + case "Bash": + case "BashOutput": { + const cmd = pick("command"); + return { + title: clean(cmd ?? toolName, TITLE_MAX), + detail: clean(pick("description"), DETAIL_MAX) || undefined, + }; + } + case "Read": + case "Write": + case "Edit": + case "MultiEdit": + case "NotebookEdit": { + const file = pick("file_path", "notebook_path", "path"); + return { + title: file ? shortPath(clean(file, TITLE_MAX)) : toolName, + detail: file ? clean(file, DETAIL_MAX) : undefined, + }; + } + case "Glob": + case "Grep": { + const pattern = pick("pattern", "query"); + const where = pick("path", "glob"); + return { + title: clean(pattern ?? toolName, TITLE_MAX), + detail: where ? clean(where, DETAIL_MAX) : undefined, + }; + } + case "WebFetch": + case "WebSearch": { + const target = pick("url", "query"); + return { title: clean(target ?? toolName, TITLE_MAX) }; + } + case "Task": + case "Agent": { + return { + title: clean(pick("description", "prompt") ?? toolName, TITLE_MAX), + detail: clean(pick("subagent_type"), DETAIL_MAX) || undefined, + }; + } + default: { + const first = pick("command", "file_path", "path", "url", "query", "pattern", "description"); + if (first) return { title: clean(first, TITLE_MAX) }; + const keys = Object.keys(obj); + if (!keys.length) return { title: toolName }; + return { title: clean(obj[keys[0]], TITLE_MAX) || toolName }; + } + } +} + +export function summarizePrompt(payload: Record): string { + const prompt = + payload["prompt"] ?? payload["userPrompt"] ?? payload["message"] ?? payload["text"]; + return clean(prompt, TITLE_MAX) || "(prompt)"; +} + +export function summarizeNotification(payload: Record): string { + const message = payload["message"] ?? payload["notification"] ?? payload["text"]; + return clean(message, TITLE_MAX) || "Notification"; +} + +export function labelForWorkspace(workspaceRoot: string | undefined, cwd: string): string { + const source = workspaceRoot || cwd || ""; + return basename(source) || "workspace"; +} + +export function truncateTitle(text: string): string { + return clean(text, TITLE_MAX); +} + +export function truncateDetail(text: string): string { + return clean(text, DETAIL_MAX); +} diff --git a/server/src/webauthn.ts b/server/src/webauthn.ts new file mode 100644 index 0000000..cc4c353 --- /dev/null +++ b/server/src/webauthn.ts @@ -0,0 +1,192 @@ +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, +} from "@simplewebauthn/server"; +import { expectedOrigins, expectedRpIds, type Config } from "./config.js"; +import { + addCredential, + createAuthSession, + findCredential, + listCredentials, + touchCredential, +} from "./store.js"; + +/** Passkeys live for a month before the phone has to prove itself again. */ +export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000; + +const CHALLENGE_TTL_MS = 5 * 60_000; +const MAX_CHALLENGES = 32; + +/** + * There is exactly one logical user here — you. A stable handle means a second passkey + * enrolled later joins the same account instead of creating a parallel one. + */ +const USER_HANDLE = new TextEncoder().encode("grok-glance"); +const USER_NAME = "grok-glance"; + +type Purpose = "register" | "authenticate"; + +/** + * Challenges are held server-side and consumed exactly once. SimpleWebAuthn lets us pass a + * predicate for `expectedChallenge`, so we never need to trust the client to tell us which + * challenge it was answering. + */ +class ChallengeStore { + private items = new Map(); + + issue(challenge: string, purpose: Purpose): void { + this.prune(); + if (this.items.size >= MAX_CHALLENGES) { + // Drop the oldest rather than growing without bound. + const oldest = this.items.keys().next(); + if (!oldest.done) this.items.delete(oldest.value); + } + this.items.set(challenge, { purpose, expiresAt: Date.now() + CHALLENGE_TTL_MS }); + } + + consume(challenge: string, purpose: Purpose): boolean { + this.prune(); + const entry = this.items.get(challenge); + if (!entry || entry.purpose !== purpose) return false; + this.items.delete(challenge); + return true; + } + + private prune(): void { + const now = Date.now(); + for (const [key, value] of this.items) { + if (value.expiresAt <= now) this.items.delete(key); + } + } +} + +export interface AuthOutcome { + ok: boolean; + error?: string; + token?: string; + label?: string; +} + +export class WebAuthnService { + private challenges = new ChallengeStore(); + + constructor(private readonly cfg: Config) {} + + private get rpId(): string { + return this.cfg.rpId ?? "localhost"; + } + + get enrolled(): boolean { + return listCredentials().length > 0; + } + + async registrationOptions() { + const existing = listCredentials(); + const options = await generateRegistrationOptions({ + rpName: this.cfg.rpName, + rpID: this.rpId, + userID: USER_HANDLE, + userName: USER_NAME, + userDisplayName: this.cfg.rpName, + attestationType: "none", + // Don't let the same device enrol twice — it just confuses the device list. + excludeCredentials: existing.map((c) => ({ + id: c.id, + transports: c.transports, + })), + authenticatorSelection: { + residentKey: "required", + // "Strictly guarded" means a biometric or PIN every time, not merely possession. + userVerification: "required", + }, + timeout: 120_000, + }); + this.challenges.issue(options.challenge, "register"); + return options; + } + + async verifyRegistration( + response: RegistrationResponseJSON, + label: string, + ): Promise { + let verification; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: (challenge) => this.challenges.consume(challenge, "register"), + expectedOrigin: expectedOrigins(this.cfg), + expectedRPID: expectedRpIds(this.cfg), + requireUserVerification: true, + }); + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + + if (!verification.verified || !verification.registrationInfo) { + return { ok: false, error: "registration could not be verified" }; + } + + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + addCredential({ + id: credential.id, + publicKey: Buffer.from(credential.publicKey).toString("base64"), + counter: credential.counter, + transports: credential.transports, + label: label.trim().slice(0, 40) || "device", + createdAt: Date.now(), + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + }); + + const session = createAuthSession(credential.id, label, SESSION_TTL_MS); + return { ok: true, token: session.token, label }; + } + + async authenticationOptions() { + const options = await generateAuthenticationOptions({ + rpID: this.rpId, + allowCredentials: listCredentials().map((c) => ({ + id: c.id, + transports: c.transports, + })), + userVerification: "required", + timeout: 120_000, + }); + this.challenges.issue(options.challenge, "authenticate"); + return options; + } + + async verifyAuthentication(response: AuthenticationResponseJSON): Promise { + const stored = findCredential(response.id); + if (!stored) return { ok: false, error: "unknown device" }; + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: (challenge) => this.challenges.consume(challenge, "authenticate"), + expectedOrigin: expectedOrigins(this.cfg), + expectedRPID: expectedRpIds(this.cfg), + credential: { + id: stored.id, + publicKey: new Uint8Array(Buffer.from(stored.publicKey, "base64")), + counter: stored.counter, + transports: stored.transports, + }, + requireUserVerification: true, + }); + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + + if (!verification.verified) return { ok: false, error: "assertion rejected" }; + + touchCredential(stored.id, verification.authenticationInfo.newCounter); + const session = createAuthSession(stored.id, stored.label, SESSION_TTL_MS); + return { ok: true, token: session.token, label: stored.label }; + } +} diff --git a/skills/glance/SKILL.md b/skills/glance/SKILL.md new file mode 100644 index 0000000..52c7132 --- /dev/null +++ b/skills/glance/SKILL.md @@ -0,0 +1,83 @@ +--- +name: glance +description: Set up, inspect, or control grok-glance — the passkey-guarded phone dashboard for this Grok Build session. Use when the user asks to watch a session from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off. +--- + +# grok-glance + +A local daemon plus web dashboard that shows what Grok Build is doing, readable from a phone +behind a WebAuthn passkey. It can also pause risky tool calls until someone taps approve. + +The daemon is started automatically by the `SessionStart` hook. Everything below is done through +the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`. + +## First check whether it is even built + +The plugin ships as TypeScript and must be built once: + +```sh +cd "$GROK_PLUGIN_ROOT" && npm install && npm run build +``` + +`glance status` prints a "not built" error with this same instruction if it is missing. Do not +attempt to skip the build — the daemon entry point is `dist/server/index.js`. + +## The commands + +```sh +glance status # is it running, which origin, how many devices +glance up # start the daemon in the background +glance stop # stop it +glance logs # last 60 lines of the daemon log +glance enroll # mint a one-time code + URL for a new phone +glance set-origin # set the public origin and WebAuthn RP ID +glance devices # list enrolled devices +glance revoke # revoke one +glance approval # remote approve/deny policy +glance sync-hooks # rewrite hook URLs after changing the port +``` + +## Getting it onto a phone + +The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare +IP can never be a WebAuthn RP ID — so the supported path is Tailscale Serve: + +```sh +tailscale serve --bg 127.0.0.1:8791 +tailscale serve status # read the https://..ts.net URL +glance set-origin https://..ts.net +glance enroll +``` + +Then open the printed URL on the phone, type the code, and create the passkey. The phone must be +on the same tailnet. + +Changing the origin changes the RP ID, which invalidates existing passkeys. Say so before running +`set-origin` on a working setup. + +## Remote approve/deny + +`glance approval risky` makes `Bash`, `Write`, `Edit`, `MultiEdit` and `NotebookEdit` calls pause +and wait for a tap on the phone. Defaults that matter: + +- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`). +- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's + settings panel if you want the opposite. +- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This + is a convenience gate, not a security boundary. + +`glance approval off` (the default) means Grok Build never blocks on the phone. + +## When something does not work + +- **"not running"** → `glance up`, then `glance logs`. +- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not + cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar. +- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in + `hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it. +- **Page says "run npm install && npm run build"** → the web bundle is missing; build it. + +## What it deliberately does not do + +Read-only plus approve/deny. It cannot send prompts, edit files, run tools, or resume a session. +Do not tell the user otherwise. diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..005eeb8 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "outDir": "dist/server", + "rootDir": "server/src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true + }, + "include": ["server/src"] +} diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..c4200c4 --- /dev/null +++ b/tsconfig.web.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "paths": { + "@/*": ["./web/src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["web/src", "web/vite-env.d.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5d888f1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { fileURLToPath } from "node:url"; + +// The web app lives in web/ and is emitted into dist/web, which the daemon serves. +export default defineConfig({ + root: "web", + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./web/src", import.meta.url)), + }, + }, + build: { + outDir: "../dist/web", + emptyOutDir: true, + }, + server: { + // `npm run dev` serves the UI on 5173 and proxies the API to the daemon. + proxy: { + "/api": "http://127.0.0.1:8791", + "/events": { + target: "http://127.0.0.1:8791", + changeOrigin: false, + }, + }, + }, +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e4d8ced --- /dev/null +++ b/web/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + grok-glance + + + + + + + +
+ + + diff --git a/web/public/icon.svg b/web/public/icon.svg new file mode 100644 index 0000000..d58587d --- /dev/null +++ b/web/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..e0daaa0 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "grok-glance", + "short_name": "glance", + "description": "Glance at what Grok Build is doing.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#09090b", + "theme_color": "#09090b", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..300f46b --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { Alert, Button, Card, Spinner, useTheme } from "@heroui/react"; +import { api } from "@/lib/api"; +import { useGlance, useNow } from "@/lib/useGlance"; +import { Gate } from "@/components/Gate"; +import { NowCard } from "@/components/NowCard"; +import { PendingCard } from "@/components/PendingCard"; +import { SessionsCard } from "@/components/SessionsCard"; +import { SettingsPanel } from "@/components/SettingsPanel"; +import { Timeline } from "@/components/Timeline"; +import { GearIcon } from "@/components/icons"; +import type { GateInfo } from "@/protocol"; + +export default function App() { + const [gate, setGate] = useState(null); + const [fatal, setFatal] = useState(null); + // HeroUI's own hook: it writes the `dark` class and `data-theme` in a layout effect (no + // flash), follows the OS while the intent is "system", and persists an explicit choice. + const { resolvedTheme, setTheme } = useTheme(); + const [showSettings, setShowSettings] = useState(false); + const [selected, setSelected] = useState(null); + const [busyIds, setBusyIds] = useState([]); + + const refreshGate = useCallback(async () => { + try { + setGate(await api.gate()); + setFatal(null); + } catch (err) { + setFatal((err as Error).message); + } + }, []); + + useEffect(() => { + void refreshGate(); + }, [refreshGate]); + + const authenticated = !!gate?.authenticated; + const { snapshot, connection } = useGlance(authenticated); + const now = useNow(1000); + + // A dropped stream is usually the network, but it is also how an expired session shows up. + // Re-checking the gate turns the second case back into the unlock screen instead of a spinner. + useEffect(() => { + if (!authenticated || connection !== "offline") return; + const id = window.setTimeout(() => void refreshGate(), 3000); + return () => window.clearTimeout(id); + }, [authenticated, connection, refreshGate]); + + async function resolve(id: string, decision: "allow" | "deny") { + setBusyIds((ids) => [...ids, id]); + try { + await api.resolveApproval(id, decision); + } catch { + // Losing the race is normal: it may have timed out, or another device answered first. + } finally { + setBusyIds((ids) => ids.filter((x) => x !== id)); + } + } + + if (fatal && !gate) { + return ( + + + + Cannot reach the daemon + {fatal} + + + + + ); + } + + if (!gate) { + return ( + + + + ); + } + + if (!authenticated) { + return void refreshGate()} />; + } + + const sessions = snapshot?.sessions ?? []; + const focus = sessions.find((s) => s.id === selected) ?? sessions[0]; + const pending = snapshot?.pending ?? []; + + return ( +
+
+
+
+
+ +
+ {pending.map((approval) => ( + + ))} + + {showSettings && snapshot && ( + setTheme(resolvedTheme === "dark" ? "light" : "dark")} + onSignedOut={() => void refreshGate()} + /> + )} + + {!snapshot ? ( + + + + ) : sessions.length === 0 ? ( + + + Nothing to show yet + + Start Grok Build in a workspace and this page will fill in as it works. + + + + ) : ( + <> + {focus && } + {sessions.length > 1 && ( + + )} + + + )} +
+
+ ); +} + +function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/components/Gate.tsx b/web/src/components/Gate.tsx new file mode 100644 index 0000000..2dbf2d3 --- /dev/null +++ b/web/src/components/Gate.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { Alert, Button, Card, Input, Spinner } from "@heroui/react"; +import { browserSupportsWebAuthn } from "@simplewebauthn/browser"; +import { api } from "@/lib/api"; +import { FingerprintIcon, LockIcon } from "@/components/icons"; +import type { GateInfo } from "@/protocol"; + +/** A friendly default so most people never touch the label field. */ +function guessDeviceName(): string { + const ua = navigator.userAgent; + if (/iPhone/.test(ua)) return "iPhone"; + if (/iPad/.test(ua)) return "iPad"; + if (/Android/.test(ua)) return "Android phone"; + if (/Macintosh/.test(ua)) return "Mac"; + if (/Windows/.test(ua)) return "Windows PC"; + return "device"; +} + +function messageFor(err: unknown): string { + const e = err as { name?: string; message?: string }; + if (e?.name === "NotAllowedError") return "Cancelled, or the prompt timed out. Try again."; + if (e?.name === "InvalidStateError") return "This device is already enrolled — just sign in."; + if (e?.name === "SecurityError") { + return "The browser refused this origin. Passkeys need the exact https hostname the daemon was configured with."; + } + return e?.message ?? "Something went wrong."; +} + +export function Gate({ gate, onSignedIn }: { gate: GateInfo; onSignedIn: () => void }) { + const wantsEnroll = new URLSearchParams(location.search).has("enroll"); + const [enrolling, setEnrolling] = useState(!gate.enrolled || wantsEnroll); + const [code, setCode] = useState(""); + const [label, setLabel] = useState(guessDeviceName); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const supported = browserSupportsWebAuthn(); + // The commonest setup failure by far: the page was opened on a hostname the RP ID does not + // cover, e.g. the LAN IP instead of the tailnet name. Say so before the prompt fails. + const hostMismatch = + !!gate.rpId && + location.hostname !== gate.rpId && + !location.hostname.endsWith(`.${gate.rpId}`) && + location.hostname !== "localhost"; + + async function run(fn: () => Promise) { + setBusy(true); + setError(null); + try { + await fn(); + onSignedIn(); + } catch (err) { + setError(messageFor(err)); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + + +
+

grok-glance

+

+ Only enrolled devices get past this screen. +

+
+
+ + {!supported && ( + + + This browser cannot do passkeys + + Open the dashboard in Safari or Chrome over https. + + + + )} + + {hostMismatch && ( + + + Wrong hostname for this passkey + + You are on {location.hostname}, but the daemon expects {gate.rpId}. Open that + hostname instead, or run{" "} + glance set-origin. + + + + )} + + {error && ( + + + {error} + + + )} + + {enrolling ? ( + + + Enrol this device + + Run glance enroll on the machine running + Grok Build, then type the code it prints. + + + + + + + + + {gate.enrolled && ( + + )} + + + ) : ( + + + Unlock + Use the passkey on this device. + + + + + + + )} + +

+ grok-glance {gate.version} + {gate.rpId ? ` · ${gate.rpId}` : ""} +

+
+ ); +} diff --git a/web/src/components/NowCard.tsx b/web/src/components/NowCard.tsx new file mode 100644 index 0000000..bff5a17 --- /dev/null +++ b/web/src/components/NowCard.tsx @@ -0,0 +1,71 @@ +import { Card, Spinner } from "@heroui/react"; +import { StateChip, ToolChip } from "@/components/StatusChip"; +import { duration, relTime } from "@/lib/format"; +import type { SessionView } from "@/protocol"; + +export function NowCard({ session, now }: { session: SessionView; now: number }) { + const tool = session.currentTool; + + return ( + + +
+
+ {session.label} + {session.cwd} +
+ +
+
+ + + {session.lastPrompt && ( +
+

+ Last asked +

+

{session.lastPrompt}

+
+ )} + + {tool ? ( +
+ +
+
+ + + {duration(Math.max(0, now - tool.startedAt))} + +
+

{tool.title}

+
+
+ ) : ( +

+ Nothing running · last activity {relTime(session.lastActivity, now)} +

+ )} +
+ + +
+ + 0} /> + 0} /> +
+
+
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: number; tone?: boolean }) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +} diff --git a/web/src/components/PendingCard.tsx b/web/src/components/PendingCard.tsx new file mode 100644 index 0000000..935018e --- /dev/null +++ b/web/src/components/PendingCard.tsx @@ -0,0 +1,73 @@ +import { Button, Card } from "@heroui/react"; +import { BanIcon, CheckIcon } from "@/components/icons"; +import { ToolChip } from "@/components/StatusChip"; +import { secondsLeft } from "@/lib/format"; +import type { PendingApproval } from "@/protocol"; + +export function PendingCard({ + approval, + now, + busy, + onResolve, +}: { + approval: PendingApproval; + now: number; + busy: boolean; + onResolve: (id: string, decision: "allow" | "deny") => void; +}) { + const left = secondsLeft(approval.expiresAt, now); + const total = Math.max(1, approval.expiresAt - approval.createdAt); + const remaining = Math.max(0, Math.min(1, (approval.expiresAt - now) / total)); + + return ( + + +
+ Waiting on you + {left}s +
+ + + in {approval.sessionLabel} + +
+ + +

{approval.title}

+ {approval.detail && ( +
+            {approval.detail}
+          
+ )} + {/* A bar rather than only a number: you can see at a glance how much time is left. */} +
+
+
+ + + + + + + + ); +} diff --git a/web/src/components/SessionsCard.tsx b/web/src/components/SessionsCard.tsx new file mode 100644 index 0000000..c46affe --- /dev/null +++ b/web/src/components/SessionsCard.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import { Card } from "@heroui/react"; +import { StateChip } from "@/components/StatusChip"; +import { relTime } from "@/lib/format"; +import type { SessionView } from "@/protocol"; + +/** + * Only rendered when more than one session is live. With a single workspace the Now card + * already says everything, and an extra list is just noise on a small screen. + */ +export function SessionsCard({ + sessions, + selectedId, + onSelect, + now, +}: { + sessions: SessionView[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + now: number; +}) { + return ( + + + Sessions + + Tap one to filter the activity list. + + + +
    +
  • + onSelect(null)}> + All sessions + {sessions.length} + +
  • + {sessions.map((session) => ( +
  • + onSelect(session.id === selectedId ? null : session.id)} + > + {session.label} + + {relTime(session.lastActivity, now)} + + + +
  • + ))} +
+
+
+ ); +} + +function Row({ + active, + onPress, + children, +}: { + active: boolean; + onPress: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..6346441 --- /dev/null +++ b/web/src/components/SettingsPanel.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Card } from "@heroui/react"; +import { api } from "@/lib/api"; +import { MoonIcon, SunIcon } from "@/components/icons"; +import type { ApprovalMode, ApprovalSettings, DeviceInfo } from "@/protocol"; + +type Patch = Parameters[0]; + +const MODES: { value: ApprovalMode; label: string; hint: string }[] = [ + { value: "off", label: "Off", hint: "Grok Build never waits for you." }, + { value: "risky", label: "Risky", hint: "Shell commands and file writes need a tap." }, + { value: "all", label: "All", hint: "Every tool call needs a tap. Noisy." }, +]; + +export function SettingsPanel({ + approval, + deviceLabel, + version, + theme, + onToggleTheme, + onSignedOut, +}: { + approval: ApprovalSettings; + deviceLabel?: string; + version: string; + theme: "light" | "dark"; + onToggleTheme: () => void; + onSignedOut: () => void; +}) { + const [local, setLocal] = useState(approval); + const [error, setError] = useState(null); + const [devices, setDevices] = useState(null); + const [currentId, setCurrentId] = useState(""); + + // Keep in step with the stream: another device may have changed the policy. + useEffect(() => setLocal(approval), [approval]); + + useEffect(() => { + api.devices().then( + (out) => { + setDevices(out.devices); + setCurrentId(out.current); + }, + () => setDevices([]), + ); + }, []); + + async function apply(patch: Patch) { + setError(null); + const previous = local; + setLocal({ ...local, ...patch }); + try { + setLocal(await api.setApproval(patch)); + } catch (err) { + setLocal(previous); + setError((err as Error).message); + } + } + + const hint = MODES.find((m) => m.value === local.mode)?.hint; + + return ( +
+ {error && ( + + + {error} + + + )} + + + + Remote approval + + Which tool calls should pause and wait for a tap on this phone. + + + +
+ {MODES.map((mode) => ( + + ))} +
+ {hint &&

{hint}

} + + apply({ requireWatcher: value })} + /> + apply({ onTimeout: value ? "deny" : "allow" })} + /> + + {local.mode !== "off" && ( +

+ risky pattern: {local.riskyPattern} +

+ )} +
+
+ + + + Devices + + Revoke from the terminal with glance revoke <id> + . + + + + {devices === null ? ( +

Loading…

+ ) : ( +
    + {devices.map((device) => ( +
  • +
    +

    + {device.label} + {device.id === currentId && ( + (this one) + )} +

    +

    {device.id.slice(0, 16)}…

    +
    + + {new Date(device.createdAt).toLocaleDateString()} + +
  • + ))} +
+ )} +
+
+ + + + + +

grok-glance {version}

+
+
+
+ ); +} + +/** + * A two-state row built from a Button rather than a switch: the whole row is a large tap + * target, which matters more on a phone than the affordance of a sliding thumb. + */ +function Toggle({ + label, + hint, + value, + onChange, +}: { + label: string; + hint: string; + value: boolean; + onChange: (value: boolean) => void; +}) { + return ( +
+
+

{label}

+

{hint}

+
+ +
+ ); +} diff --git a/web/src/components/StatusChip.tsx b/web/src/components/StatusChip.tsx new file mode 100644 index 0000000..cff65ac --- /dev/null +++ b/web/src/components/StatusChip.tsx @@ -0,0 +1,29 @@ +import { Chip } from "@heroui/react"; +import type { SessionState } from "@/protocol"; + +type ChipColor = "accent" | "danger" | "default" | "success" | "warning"; + +const STATE: Record = { + working: { color: "accent", label: "working" }, + waiting: { color: "warning", label: "waiting on you" }, + idle: { color: "success", label: "idle" }, + error: { color: "danger", label: "error" }, + ended: { color: "default", label: "ended" }, +}; + +export function StateChip({ state, size = "sm" }: { state: SessionState; size?: "sm" | "md" }) { + const { color, label } = STATE[state]; + return ( + + {label} + + ); +} + +export function ToolChip({ tool }: { tool: string }) { + return ( + + {tool} + + ); +} diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx new file mode 100644 index 0000000..45495ed --- /dev/null +++ b/web/src/components/Timeline.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Button, Card } from "@heroui/react"; +import { clockTime, duration } from "@/lib/format"; +import type { EventKind, GlanceEvent } from "@/protocol"; + +const DOT: Record = { + session_start: "bg-muted", + session_end: "bg-muted", + prompt: "bg-accent", + tool_start: "bg-muted", + tool_end: "bg-success", + tool_fail: "bg-danger", + permission_denied: "bg-danger", + turn_end: "bg-accent", + turn_error: "bg-danger", + notification: "bg-warning", + subagent_start: "bg-muted", + subagent_end: "bg-muted", + compact: "bg-muted", + approval_request: "bg-warning", + approval_allowed: "bg-success", + approval_denied: "bg-danger", + approval_expired: "bg-warning", +}; + +const PAGE = 40; + +/** + * PreToolUse rows are hidden: PostToolUse reports the same call with a duration, and the + * running one is already the headline of the Now card. Showing both doubles every line. + */ +function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[] { + return events.filter( + (event) => + event.kind !== "tool_start" && (sessionId === null || event.sessionId === sessionId), + ); +} + +export function Timeline({ + events, + sessionId, +}: { + events: GlanceEvent[]; + sessionId: string | null; +}) { + const [limit, setLimit] = useState(PAGE); + const rows = visible(events, sessionId); + const shown = rows.slice(0, limit); + + return ( + + + Activity + + {rows.length === 0 ? "Nothing yet." : `${rows.length} events`} + + + + +
    + {shown.map((event) => ( +
  1. +
  2. + ))} +
+
+ + {rows.length > shown.length && ( + + + + )} +
+ ); +} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx new file mode 100644 index 0000000..25460c9 --- /dev/null +++ b/web/src/components/icons.tsx @@ -0,0 +1,130 @@ +/** Hand-rolled icons: no icon package, so the bundle stays small and offline-safe. */ + +interface IconProps { + className?: string; +} + +const base = "h-4 w-4 shrink-0"; + +export function LockIcon({ className }: IconProps) { + return ( + + ); +} + +export function FingerprintIcon({ className }: IconProps) { + return ( + + ); +} + +export function GearIcon({ className }: IconProps) { + return ( + + ); +} + +export function CheckIcon({ className }: IconProps) { + return ( + + ); +} + +export function BanIcon({ className }: IconProps) { + return ( + + ); +} + +export function SunIcon({ className }: IconProps) { + return ( + + ); +} + +export function MoonIcon({ className }: IconProps) { + return ( + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..d93f48f --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,98 @@ +import type { + ApprovalMode, + ApprovalSettings, + DeviceInfo, + GateInfo, + Snapshot, +} from "@/protocol"; +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, +} from "@simplewebauthn/browser"; +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; + +/** + * The daemon rejects any POST without this header. A cross-origin page cannot set it without + * a CORS preflight that we never answer, so it is a second barrier behind the SameSite cookie. + */ +const POST_HEADERS = { + "content-type": "application/json", + "x-glance-csrf": "1", +}; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { credentials: "same-origin", ...init }); + const text = await res.text(); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + if (!res.ok) { + const message = + (body as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`; + throw new ApiError(message, res.status); + } + return body as T; +} + +function post(path: string, body?: unknown): Promise { + return request(path, { + method: "POST", + headers: POST_HEADERS, + body: JSON.stringify(body ?? {}), + }); +} + +export const api = { + gate: () => request("/api/gate"), + snapshot: () => request("/api/snapshot"), + devices: () => request<{ devices: DeviceInfo[]; current: string }>("/api/devices"), + logout: () => post<{ ok: true }>("/api/auth/logout"), + + resolveApproval: (id: string, decision: "allow" | "deny") => + post<{ ok: true }>("/api/approvals/resolve", { id, decision }), + + setApproval: (patch: { + mode?: ApprovalMode; + requireWatcher?: boolean; + onTimeout?: "allow" | "deny"; + }) => post("/api/approval", patch), + + /** + * Sign in with an already-enrolled passkey. The device proves itself with a biometric or + * PIN; we never see or store anything the phone could not re-derive. + */ + async signIn(): Promise { + const optionsJSON = await post( + "/api/auth/login/options", + ); + const response = await startAuthentication({ optionsJSON }); + const out = await post<{ ok: true; label?: string }>("/api/auth/login/verify", { response }); + return out.label; + }, + + /** + * Enrol this device using a one-time code from `glance enroll`. The code is checked twice — + * once to get options, once to accept the attestation — and only consumed on success. + */ + async enroll(code: string, label: string): Promise { + const optionsJSON = await post( + "/api/auth/register/options", + { code }, + ); + const response = await startRegistration({ optionsJSON }); + await post<{ ok: true }>("/api/auth/register/verify", { code, label, response }); + }, +}; diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..ab0c4f5 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,30 @@ +/** Time formatting for a screen you look at for three seconds. */ + +export function relTime(ts: number, now: number): string { + const delta = Math.max(0, now - ts); + const s = Math.round(delta / 1000); + if (s < 5) return "now"; + if (s < 60) return `${s}s ago`; + const m = Math.round(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.round(h / 24)}d ago`; +} + +export function clockTime(ts: number): string { + return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +export function duration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; + const m = Math.floor(ms / 60_000); + const s = Math.round((ms % 60_000) / 1000); + return `${m}m ${s}s`; +} + +/** Seconds left, floored at zero, for an approval countdown. */ +export function secondsLeft(expiresAt: number, now: number): number { + return Math.max(0, Math.ceil((expiresAt - now) / 1000)); +} diff --git a/web/src/lib/useGlance.ts b/web/src/lib/useGlance.ts new file mode 100644 index 0000000..bddf3ee --- /dev/null +++ b/web/src/lib/useGlance.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import type { Snapshot } from "@/protocol"; + +export type Connection = "connecting" | "live" | "offline"; + +/** Backoff caps out quickly: a phone coming out of sleep should reconnect, not sulk. */ +const RETRY_MS = [500, 1000, 2000, 4000, 8000, 15_000]; + +/** + * Subscribes to the daemon's event stream and keeps the latest full snapshot. + * + * The server sends whole snapshots rather than deltas, so a phone that slept through twenty + * events still lands on the truth with no reconciliation logic here. + */ +export function useGlance(enabled: boolean) { + const [snapshot, setSnapshot] = useState(null); + const [connection, setConnection] = useState("connecting"); + const attempt = useRef(0); + + useEffect(() => { + if (!enabled) { + setSnapshot(null); + setConnection("connecting"); + return; + } + + let stopped = false; + let source: EventSource | null = null; + let timer: number | undefined; + + const schedule = () => { + if (stopped) return; + const wait = RETRY_MS[Math.min(attempt.current, RETRY_MS.length - 1)]; + attempt.current += 1; + timer = window.setTimeout(open, wait); + }; + + const open = () => { + if (stopped) return; + // Fetch once alongside the stream so the first paint does not wait on the SSE handshake. + api.snapshot().then( + (snap) => { + if (!stopped) setSnapshot(snap); + }, + () => { + /* the stream will report the real problem */ + }, + ); + + source = new EventSource("/events", { withCredentials: true }); + + source.addEventListener("open", () => { + if (stopped) return; + attempt.current = 0; + setConnection("live"); + }); + + source.addEventListener("snapshot", (event) => { + if (stopped) return; + try { + setSnapshot(JSON.parse((event as MessageEvent).data) as Snapshot); + setConnection("live"); + } catch { + /* ignore a malformed frame rather than tearing down the stream */ + } + }); + + // The daemon says goodbye on shutdown; reconnecting will pick it up when it returns. + source.addEventListener("bye", () => { + source?.close(); + setConnection("offline"); + schedule(); + }); + + source.addEventListener("error", () => { + source?.close(); + source = null; + if (stopped) return; + setConnection("offline"); + schedule(); + }); + }; + + open(); + + // iOS suspends the stream in the background; nudge it the moment the app is looked at. + const onVisible = () => { + if (document.visibilityState !== "visible") return; + if (source && source.readyState === EventSource.OPEN) { + api.snapshot().then(setSnapshot, () => {}); + return; + } + source?.close(); + source = null; + attempt.current = 0; + if (timer) window.clearTimeout(timer); + open(); + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + stopped = true; + document.removeEventListener("visibilitychange", onVisible); + if (timer) window.clearTimeout(timer); + source?.close(); + }; + }, [enabled]); + + return { snapshot, connection }; +} + +/** A ticking clock, for countdowns and "3s ago" labels. */ +export function useNow(intervalMs = 1000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + return now; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3ee5771 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "@/App"; +import "@/styles/globals.css"; + +// HeroUI v3 needs no provider — its components carry their own state. Theme handling lives in +// the library's own `useTheme` hook, which App calls. +const host = document.getElementById("root"); +if (!host) throw new Error("missing #root"); + +createRoot(host).render( + + + , +); diff --git a/web/src/protocol.ts b/web/src/protocol.ts new file mode 100644 index 0000000..d0deab7 --- /dev/null +++ b/web/src/protocol.ts @@ -0,0 +1,107 @@ +/** + * Wire protocol shared between the daemon and the web app. + * + * NOTE: this is a copy of server/src/protocol.ts. Keep the two in sync — they are duplicated + * rather than shared because the server compiles under NodeNext while the web app compiles + * under a bundler resolution, and a single rootDir cannot span both. + */ + +export type EventKind = + | "session_start" + | "session_end" + | "prompt" + | "tool_start" + | "tool_end" + | "tool_fail" + | "permission_denied" + | "turn_end" + | "turn_error" + | "notification" + | "subagent_start" + | "subagent_end" + | "compact" + | "approval_request" + | "approval_allowed" + | "approval_denied" + | "approval_expired"; + +export type SessionState = "working" | "idle" | "waiting" | "error" | "ended"; + +export interface GlanceEvent { + id: number; + ts: number; + sessionId: string; + kind: EventKind; + /** Tool name, for tool-shaped events. */ + tool?: string; + /** One-line human summary, already truncated and redacted. */ + title: string; + /** Optional second line, e.g. a file path or an error message. */ + detail?: string; + durationMs?: number; +} + +export interface SessionView { + id: string; + /** Basename of the workspace root — what you actually recognise on a phone. */ + label: string; + cwd: string; + state: SessionState; + startedAt: number; + lastActivity: number; + lastPrompt?: string; + currentTool?: { name: string; title: string; startedAt: number }; + counts: { tools: number; failures: number; denials: number }; +} + +export interface PendingApproval { + id: string; + sessionId: string; + sessionLabel: string; + tool: string; + title: string; + detail?: string; + createdAt: number; + expiresAt: number; +} + +export type ApprovalMode = "off" | "risky" | "all"; + +export interface ApprovalSettings { + mode: ApprovalMode; + riskyPattern: string; + timeoutMs: number; + /** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */ + requireWatcher: boolean; + /** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */ + onTimeout: "allow" | "deny"; +} + +export interface Snapshot { + now: number; + version: string; + sessions: SessionView[]; + events: GlanceEvent[]; + pending: PendingApproval[]; + approval: ApprovalSettings; +} + +export interface DeviceInfo { + id: string; + label: string; + createdAt: number; + lastUsedAt?: number; +} + +/** Everything the app needs before it knows whether you are signed in. */ +export interface GateInfo { + authenticated: boolean; + /** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */ + enrolled: boolean; + /** True while a one-time enrolment code minted by `glance enroll` is still valid. */ + enrollmentOpen: boolean; + version: string; + deviceLabel?: string; + /** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */ + rpId?: string; +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css new file mode 100644 index 0000000..1d7c7da --- /dev/null +++ b/web/src/styles/globals.css @@ -0,0 +1,44 @@ +@import "tailwindcss"; +@import "@heroui/styles"; + +@custom-variant dark (&:is(.dark *)); + +/* A dashboard you read one-handed: no rubber-band scroll surprises, no text inflation. */ +html { + -webkit-text-size-adjust: 100%; + /* useTheme sets the class and data-theme but not color-scheme, and without it the phone + paints native scrollbars and form controls light on a dark page. */ + color-scheme: light; +} + +html.dark { + color-scheme: dark; +} + +body { + min-height: 100dvh; + overscroll-behavior-y: none; +} + +/* Keep the timeline scrollable without a visible scrollbar eating width on mobile. */ +.glance-scroll { + scrollbar-width: thin; +} +.glance-scroll::-webkit-scrollbar { + width: 6px; +} +.glance-scroll::-webkit-scrollbar-thumb { + border-radius: 3px; + background: color-mix(in oklab, currentColor 20%, transparent); +} + +/* Respect a user who has asked the OS to calm things down. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/vite-env.d.ts b/web/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/vite-env.d.ts @@ -0,0 +1 @@ +///