commit e8e37d77fe3a2da50bddb0b057178762a860cb85 Author: agent Date: Sat Jun 13 21:23:10 2026 +0200 feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway) Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus peer for the browser. Consumes unibus as a Go module via replace => ../unibus, keeping its own replace fn-registry for the cybersecurity primitives. go build/vet/test and pnpm build green in the new location. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3b5510 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/web/node_modules/ +/web/dist/ +/web/*.tsbuildinfo +*.log +/webgw +/uniweb +local_files/ diff --git a/app.md b/app.md new file mode 100644 index 0000000..1448c0e --- /dev/null +++ b/app.md @@ -0,0 +1,125 @@ +--- +name: uniweb +lang: go +domain: infra +version: 0.1.0 +description: "Frontend web del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) + gateway Go (REST+SSE) que actúa de peer del bus para el navegador." +tags: [service, messaging, web, frontend, e2e] +uses_functions: + - generate_identity_go_cybersecurity + - seal_aead_go_cybersecurity + - open_aead_go_cybersecurity + - seal_key_box_go_cybersecurity + - open_key_box_go_cybersecurity + - sign_ed25519_go_cybersecurity + - verify_ed25519_go_cybersecurity +uses_types: [] +framework: "react" +entry_point: "cmd/webgw" +dir_path: "projects/message_bus/apps/uniweb" +repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/uniweb" +icon: + phosphor: "chats-circle" + accent: "#6366f1" +service: + port: 8481 + health_endpoint: null + health_timeout_s: 3 + systemd_unit: null + systemd_scope: null + restart_policy: always + runtime: manual + pc_targets: + - lucas-linux + is_local_only: false +e2e_checks: + - id: build + cmd: "CGO_ENABLED=0 go build ./..." + timeout_s: 180 + - id: vet + cmd: "CGO_ENABLED=0 go vet ./..." + timeout_s: 120 + - id: unit + cmd: "CGO_ENABLED=0 go test ./..." + timeout_s: 120 + - id: web_build + cmd: "cd web && pnpm install --frozen-lockfile && pnpm build" + timeout_s: 180 +--- + +## Qué es + +`uniweb` es el frontend web del bus [unibus](../unibus/app.md): la interfaz que un humano +usa desde el navegador para hablar por el bus. Se separó de `unibus` (v0.13.0) para que el +plano del bus (membresía, claves, librería cliente) quede limpio y el frontend tenga su +propia carpeta de servicio y su propio ciclo de release. + +Tiene dos mitades que viven juntas: + +- **SPA (`web/`)** — React 18 + Vite + Mantine v9. Pantallas de chat y onboarding wallet + (join por invitación, login por passphrase local, recover por mnemónica). La identidad + criptográfica de cada usuario se deriva de forma determinista de una frase BIP39 de 12 + palabras y se cifra at-rest en el dispositivo (AES-256-GCM); la clave privada nunca viaja + al servidor en claro. +- **Gateway (`cmd/webgw`)** — binario Go (`package main`, REST + SSE) que actúa como peer + del bus en nombre del navegador. Mantiene una sesión wallet por usuario, registra claves + públicas por token de invitación, y traduce HTTP/SSE ↔ el protocolo del bus usando la + librería cliente de unibus. + +## Cómo se acopla a unibus + +`uniweb` consume `unibus` como **módulo Go**, no reimplementa nada del bus: + +``` +replace github.com/enmanuel/unibus => ../unibus # pkg/{busauth,client,frame,room} +replace fn-registry => ../../../../ # functions/cybersecurity +``` + +Los `replace` no son transitivos en Go, así que `uniweb` (módulo principal) declara los dos: +el de `unibus` (de donde importa la librería cliente) y el de `fn-registry` (de donde +`pkg/client` toma las primitivas de cifrado). Compila con `CGO_ENABLED=0` igual que unibus. + +## Ejemplo + +```bash +# 1. Backend: el control-plane del bus (en la carpeta de unibus) +cd ../unibus && CGO_ENABLED=0 go run ./cmd/membershipd # :8470 + +# 2. Build de la SPA +cd web && pnpm install && pnpm build # genera web/dist + +# 3. Gateway sirviendo la SPA + API contra el control-plane +cd .. && CGO_ENABLED=0 go run ./cmd/webgw \ + --port 8481 --ctrl-url http://127.0.0.1:8470 --web-dir web/dist +# Navegador: http://127.0.0.1:8481 + +# Desarrollo de la SPA con hot-reload (gateway en modo API-only, sin --web-dir): +cd web && pnpm dev # vite proxya /api + /stream al gateway +``` + +## Cuándo usarla + +Cuando quieras que un humano hable por el bus desde un navegador, o cuando trabajes en la UI +de chat / el onboarding wallet. Para la lógica del bus en sí (membresía, claves, peers +programáticos) ve a `unibus`; `uniweb` solo es la capa web encima. + +## Gotchas + +- El gateway necesita el control-plane de unibus vivo (`--ctrl-url`, por defecto + `http://127.0.0.1:8470`); si no, las sesiones fallan al abrir el peer. +- `--web-dir` es **opcional**: vacío = API-only (úsalo con el dev server de vite); apuntando a + `web/dist` = sirve la SPA buildeada. Un path inválido degrada a API-only con un WARN, no + peta. +- Build cross-repo: `uniweb` no compila si `../unibus` no está presente en disco (el `replace` + es local). Para deploy hay que llevar ambos repos, o vendorizar unibus. +- La passphrase del wallet nunca se guarda ni se envía; perderla en un dispositivo sin la + mnemónica BIP39 = identidad irrecuperable en ese dispositivo (recuperable en otro con las 12 + palabras). + +## Capability growth log + +- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway + (`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad + respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo + cambia la ubicación y el módulo Go. go build/vet/test + pnpm build verdes en la nueva + ubicación con los `replace` cross-repo. diff --git a/cmd/webgw/gateway.go b/cmd/webgw/gateway.go new file mode 100644 index 0000000..738f761 --- /dev/null +++ b/cmd/webgw/gateway.go @@ -0,0 +1,246 @@ +package main + +import ( + "encoding/hex" + "fmt" + "strings" + "sync" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/busauth" + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/room" +) + +// gateway is the live web gateway: it owns the operator's identity and a single +// connected unibus client, and turns the bus's crypto-bearing API into the plain +// REST/SSE surface the browser consumes. The browser never signs, never speaks +// NATS, and never sees a private key — the gateway is the legitimate room member +// that seals/opens payloads on the browser's behalf. +// +// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can +// read plaintext because it acts AS the operator's client — a real member of +// each room, holding the room key K like any peer. It is the same trust a native +// desktop client has. In the wallet phase (per-browser WebCrypto identity) the +// decryption can move into the browser; today, for the single-operator MVP, the +// gateway decrypts server-side and pushes cleartext over a loopback/authenticated +// SSE channel. +type gateway struct { + id cs.Identity + endpoint string + cli *client.Client + refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus) + + mu sync.Mutex + hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients +} + +// gatewayConfig wires a live gateway. +type gatewayConfig struct { + Identity cs.Identity + NatsURL string + CtrlURL string + CtrlURLs []string + NatsURLs []string + CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd) +} + +// newGateway connects the unibus client with the operator identity following the +// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty +// means plaintext dev. When a CA is configured the bus is assumed to enforce a +// per-subject ACL, so membership changes trigger a session refresh. +func newGateway(cfg gatewayConfig) (*gateway, error) { + opts := client.Options{ + CtrlURLs: cfg.CtrlURLs, + NatsServers: cfg.NatsURLs, + } + if cfg.CAPath != "" { + tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath) + if err != nil { + return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err) + } + opts.UseNkey = true + opts.TLS = tlsCfg + opts.CtrlTLS = tlsCfg + } + cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts) + if err != nil { + return nil, fmt.Errorf("webgw: connect bus client: %w", err) + } + return &gateway{ + id: cfg.Identity, + endpoint: frame.EndpointID(cfg.Identity.SignPub), + cli: cli, + refreshACL: cfg.CAPath != "", + hubs: map[string]*roomHub{}, + }, nil +} + +// Close stops every hub and releases the bus client connection. +func (g *gateway) Close() error { + g.mu.Lock() + for _, h := range g.hubs { + h.stop() + } + g.hubs = map[string]*roomHub{} + g.mu.Unlock() + if g.cli != nil { + return g.cli.Close() + } + return nil +} + +// ---- wire types (browser-facing JSON) ------------------------------------ + +// meInfo is what GET /api/me returns: the operator identity the gateway acts as. +type meInfo struct { + Endpoint string `json:"endpoint"` + SignPub string `json:"sign_pub"` +} + +// roomWire is the browser view of a room. It deliberately omits messages: those +// stream over SSE (GET /api/rooms/{id}/stream), not in the room list. +type roomWire struct { + ID string `json:"id"` + Subject string `json:"subject"` + Name string `json:"name"` + Epoch int `json:"epoch"` + Encrypt bool `json:"encrypt"` + Persist bool `json:"persist"` + SignMsgs bool `json:"sign_msgs"` + Role string `json:"role"` +} + +// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are +// pointers so an omitted field falls back to the chat default rather than to the +// Go zero value (false). The common case — the browser sending only {subject, +// encrypted} — maps encrypted onto all three (the Matrix-like chat policy). +type createRoomReq struct { + Subject string `json:"subject"` + Encrypted *bool `json:"encrypted,omitempty"` + Encrypt *bool `json:"encrypt,omitempty"` + Persist *bool `json:"persist,omitempty"` + SignMsgs *bool `json:"sign_msgs,omitempty"` +} + +// policy resolves the requested policy. A bare {subject} defaults to the +// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps +// durable, end-to-end-encrypted, authored history. Callers can override any leg. +func (r createRoomReq) policy() room.Policy { + enc, per, sig := true, true, true + if r.Encrypted != nil { + enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted + } + if r.Encrypt != nil { + enc = *r.Encrypt + } + if r.Persist != nil { + per = *r.Persist + } + if r.SignMsgs != nil { + sig = *r.SignMsgs + } + return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig} +} + +// sendReq is the POST /api/rooms/{id}/send body. +type sendReq struct { + Body string `json:"body"` +} + +// msgWire is one decrypted message pushed over SSE. +type msgWire struct { + ID string `json:"id"` + Sender string `json:"sender"` + Body string `json:"body"` + TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id) + Mine bool `json:"mine"` +} + +// ---- operations ----------------------------------------------------------- + +func (g *gateway) me() meInfo { + return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)} +} + +// subjectName derives a short, human-friendly room name from its bus subject by +// dropping the leading namespace segment (room., test., proc., agent.). It is a +// display nicety only; the canonical identity stays the subject/room id. +func subjectName(subject string) string { + for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} { + if strings.HasPrefix(subject, p) { + return strings.TrimPrefix(subject, p) + } + } + return subject +} + +func (g *gateway) listRooms() ([]roomWire, error) { + rooms, err := g.cli.ListMyRooms() + if err != nil { + return nil, err + } + out := make([]roomWire, 0, len(rooms)) + for _, rm := range rooms { + out = append(out, roomWire{ + ID: rm.RoomID, + Subject: rm.Subject, + Name: subjectName(rm.Subject), + Epoch: rm.Epoch, + Encrypt: rm.Policy.Encrypt, + Persist: rm.Policy.Persist, + SignMsgs: rm.Policy.SignMsgs, + Role: rm.Role, + }) + } + return out, nil +} + +func (g *gateway) createRoom(req createRoomReq) (roomWire, error) { + subject := strings.TrimSpace(req.Subject) + if subject == "" { + return roomWire{}, fmt.Errorf("webgw: subject required") + } + p := req.policy() + roomID, err := g.cli.CreateRoom(subject, p) + if err != nil { + return roomWire{}, err + } + // Under a per-subject ACL the operator's frozen NATS permissions do not yet + // cover the new room's subject; refresh so subsequent data-plane use works. On + // a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any + // live SSE subscriptions, so it is gated on the secured posture. + if g.refreshACL { + _ = g.cli.RefreshSession() + } + return roomWire{ + ID: roomID, + Subject: subject, + Name: subjectName(subject), + Epoch: 1, + Encrypt: p.Encrypt, + Persist: p.Persist, + SignMsgs: p.SignMsgs, + Role: "owner", + }, nil +} + +// join resolves room metadata and (for encrypted rooms) fetches the room key so +// the gateway can later open payloads. Idempotent. +func (g *gateway) join(roomID string) error { + if err := g.cli.Join(roomID); err != nil { + return err + } + if g.refreshACL { + _ = g.cli.RefreshSession() + } + return nil +} + +// send publishes plaintext to a room. The unibus client seals it with the room +// key (encrypted rooms) and signs it (signed rooms) before it leaves the process. +func (g *gateway) send(roomID, body string) error { + return g.cli.Publish(roomID, []byte(body)) +} diff --git a/cmd/webgw/hub.go b/cmd/webgw/hub.go new file mode 100644 index 0000000..523dea0 --- /dev/null +++ b/cmd/webgw/hub.go @@ -0,0 +1,140 @@ +package main + +import ( + "sync" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/oklog/ulid/v2" +) + +// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The +// unibus client derives a per-(room, endpoint) durable consumer name, so a +// second Subscribe for the same room from the same operator would contend for +// the same durable (load-balanced delivery) rather than each browser receiving +// every message. The hub holds a single subscription per room and fans each +// decrypted frame out to every connected browser, which also means the gateway +// opens at most one bus subscription per room regardless of how many tabs watch +// it. +type roomHub struct { + roomID string + myEndpoint string + sub *client.Sub + + mu sync.Mutex + clients map[chan msgWire]struct{} +} + +// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A +// malformed id (should not happen for bus-produced frames) yields 0, which the +// browser renders without crashing. +func frameTS(msgID string) int64 { + id, err := ulid.Parse(msgID) + if err != nil { + return 0 + } + return int64(id.Time()) +} + +// newRoomHub opens the single bus subscription for roomID and starts fanning +// decrypted frames out to registered clients. The room must already be joined +// (so the gateway holds the room key) before this is called. +func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) { + h := &roomHub{ + roomID: roomID, + myEndpoint: myEndpoint, + clients: map[chan msgWire]struct{}{}, + } + sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { + m := msgWire{ + ID: f.MsgID, + Sender: f.Sender, + Body: string(plaintext), + TS: frameTS(f.MsgID), + Mine: f.Sender == myEndpoint, + } + h.broadcast(m) + }) + if err != nil { + return nil, err + } + h.sub = sub + return h, nil +} + +// broadcast delivers a message to every registered client without blocking the +// NATS delivery goroutine: a client whose buffer is full (a stalled browser) +// drops this frame rather than stalling the whole room. +func (h *roomHub) broadcast(m msgWire) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- m: + default: + } + } +} + +// add registers a new SSE client channel. +func (h *roomHub) add(ch chan msgWire) { + h.mu.Lock() + defer h.mu.Unlock() + h.clients[ch] = struct{}{} +} + +// stop unsubscribes from the bus. Local delivery ends; for a persisted room the +// durable consumer's ack position stays on the server, so a later subscription +// with the same operator resumes from where it left off. +func (h *roomHub) stop() { + if h.sub != nil { + _ = h.sub.Unsubscribe() + } +} + +// openStream joins the room (idempotent; fetches the room key for encrypted +// rooms), attaches an SSE client to the room's hub (creating it on first watcher), +// and returns the client's message channel plus a cleanup func. The cleanup +// detaches the client and, when it was the last watcher, tears down the room's +// single bus subscription. +func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) { + if err := g.join(roomID); err != nil { + return nil, nil, err + } + g.mu.Lock() + h := g.hubs[roomID] + if h == nil { + var err error + h, err = newRoomHub(g.cli, roomID, g.endpoint) + if err != nil { + g.mu.Unlock() + return nil, nil, err + } + g.hubs[roomID] = h + } + g.mu.Unlock() + + // Buffer so a brief render hitch in the browser does not drop live frames; a + // sustained stall still drops (broadcast is non-blocking) rather than wedging + // the room. + ch := make(chan msgWire, 64) + h.add(ch) + + // cleanup takes g.mu before h.mu (the single, consistent lock order) so a + // concurrent openStream that re-creates the hub cannot race the teardown. + cleanup := func() { + g.mu.Lock() + defer g.mu.Unlock() + h.mu.Lock() + delete(h.clients, ch) + empty := len(h.clients) == 0 + h.mu.Unlock() + if empty { + if cur := g.hubs[roomID]; cur == h { + delete(g.hubs, roomID) + h.stop() + } + } + } + return ch, cleanup, nil +} diff --git a/cmd/webgw/identity.go b/cmd/webgw/identity.go new file mode 100644 index 0000000..41ee3b0 --- /dev/null +++ b/cmd/webgw/identity.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + + cs "fn-registry/functions/cybersecurity" +) + +// identityJSON mirrors the on-disk / pass-stored identity format shared across +// the unibus tooling: the four keypair halves, each std-base64. It is the SAME +// shape the bus client persists (pkg/client identity file) and the operator's +// `pass` entry unibus/operator-identity, so the web gateway loads the operator's +// identity without a divergent serialization. Kept in lockstep with +// unibus_admin/internal/admin/identity.go. +type identityJSON struct { + SignPub string `json:"sign_pub"` + SignPriv string `json:"sign_priv"` + KexPub string `json:"kex_pub"` + KexPriv string `json:"kex_priv"` +} + +// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private +// halves stay only in memory; this never writes them anywhere. +func decodeIdentity(raw []byte) (cs.Identity, error) { + var f identityJSON + if err := json.Unmarshal(raw, &f); err != nil { + return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err) + } + dec := base64.StdEncoding.DecodeString + signPub, err := dec(f.SignPub) + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err) + } + signPriv, err := dec(f.SignPriv) + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err) + } + kexPub, err := dec(f.KexPub) + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err) + } + kexPriv, err := dec(f.KexPriv) + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err) + } + if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 { + return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)", + len(signPub), len(signPriv), len(kexPub), len(kexPriv)) + } + return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil +} + +// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus +// client writes) and decodes it. Used on a deploy host where `pass` is not +// available and the operator identity is delivered as a protected file. +func loadIdentityFromFile(path string) (cs.Identity, error) { + raw, err := os.ReadFile(path) + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err) + } + return decodeIdentity(raw) +} + +// loadIdentityFromPass shells out to `pass show ` and decodes the JSON +// identity it returns. The secret is held only in memory; this process never +// writes it to disk or argv. Used in local operator workflows where the GNU +// password store holds unibus/operator-identity. +func loadIdentityFromPass(entry string) (cs.Identity, error) { + out, err := exec.Command("pass", "show", entry).Output() + if err != nil { + return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err) + } + return decodeIdentity(out) +} + +// loadPassValue returns the first line of a `pass show ` for non-identity +// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and +// no error, so callers can treat "no pass entry configured" as "not set". +func loadPassValue(entry string) (string, error) { + if entry == "" { + return "", nil + } + out, err := exec.Command("pass", "show", entry).Output() + if err != nil { + return "", fmt.Errorf("webgw: pass show %q: %w", entry, err) + } + s := string(out) + for i := 0; i < len(s); i++ { + if s[i] == '\n' || s[i] == '\r' { + return s[:i], nil + } + } + return s, nil +} diff --git a/cmd/webgw/main.go b/cmd/webgw/main.go new file mode 100644 index 0000000..ae94e23 --- /dev/null +++ b/cmd/webgw/main.go @@ -0,0 +1,199 @@ +// Command webgw is the web gateway for the unibus chat SPA. It is a single Go +// binary that holds the operator's bus identity, connects to the bus as a real +// authenticated peer (pkg/client), and exposes a small REST + SSE API the +// browser consumes. The browser never signs, never speaks NATS, and never sees a +// private key: it authenticates to the gateway with a passphrase and thereafter +// holds only an opaque session cookie. +// +// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on +// the bus. The gateway can read plaintext because it acts AS the operator's +// client — a legitimate member of each room holding the room key. Decryption +// happens server-side in this process; cleartext then crosses an authenticated +// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue: +// per-browser WebCrypto identity) can move decryption into the browser; see the +// report for the FASE 2 plan. +// +// # local dev against a loopback membershipd (plaintext), operator from pass: +// webgw --identity-pass unibus/operator-identity \ +// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 +// +// # secured cluster (TLS + nkey on both planes), identity from a 0600 file: +// webgw --ca ca.crt --identity-file operator.id \ +// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \ +// --ctrl-urls https://node-b:8470,https://node-c:8470 \ +// --nats-urls nats://node-b:4250,nats://node-c:4250 +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + cs "fn-registry/functions/cybersecurity" +) + +func main() { + var ( + bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)") + port = flag.String("port", "8481", "gateway HTTP port") + ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL") + ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)") + natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL") + natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)") + caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)") + identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass") + identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity") + unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry") + unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)") + registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (/register)") + mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member") + webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)") + ) + flag.Parse() + + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("[webgw] ") + + id, err := loadIdentity(*identityFile, *identityPass) + if err != nil { + log.Fatalf("%v", err) + } + + unlock := *unlockPass + if unlock == "" { + unlock, err = loadPassValue(*unlockEntry) + if err != nil { + log.Fatalf("resolve unlock passphrase: %v", err) + } + } + if unlock == "" { + log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)") + } + + resolvedWebDir := resolveWebDir(*webDir) + + // busTemplate is the connection config every bus client uses. The operator + // gateway uses it as-is; each wallet session clones it and overrides Identity + // with the logged-in user's keypair. + busTemplate := gatewayConfig{ + Identity: id, + NatsURL: *natsURL, + CtrlURL: *ctrlURL, + CtrlURLs: splitCSV(*ctrlURLs), + NatsURLs: splitCSV(*natsURLs), + CAPath: *caPath, + } + + gw, err := newGateway(busTemplate) + if err != nil { + log.Fatalf("%v", err) + } + defer gw.Close() + + // Wallet onboarding backend: POST /api/register targets the bus's /register + // (added by the user-accounts work). When --register-url is empty we derive it + // from --ctrl-url; --mock-tokens supplies one-shot invites for local testing + // before that endpoint is deployed. + regURL := *registerURL + if regURL == "" { + regURL = strings.TrimRight(*ctrlURL, "/") + "/register" + } + registrar := newRegistrar(regURL, *mockTokens) + + log.Printf("operator endpoint: %s", gw.endpoint) + log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs))) + tls := "OFF (plaintext dev)" + if *caPath != "" { + tls = "ON (CA " + *caPath + ")" + } + log.Printf("bus TLS+nkey: %s", tls) + if resolvedWebDir != "" { + log.Printf("serving SPA from: %s", resolvedWebDir) + } else { + log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy") + } + + log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens)) + + srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir) + addr := *bind + ":" + *port + httpSrv := &http.Server{ + Addr: addr, + Handler: srv, + // No global write timeout: SSE streams are long-lived. Header timeout still + // bounds slowloris on the request line/headers. + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("web gateway: http://%s", addr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http server: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + log.Printf("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + log.Printf("bye") +} + +// loadIdentity resolves the operator identity from exactly one of --identity-file +// or --identity-pass. +func loadIdentity(file, passEntry string) (cs.Identity, error) { + switch { + case file != "" && passEntry != "": + return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass") + case file != "": + return loadIdentityFromFile(file) + case passEntry != "": + return loadIdentityFromPass(passEntry) + default: + return cs.Identity{}, errFlag("an identity is required: pass --identity-file or --identity-pass ") + } +} + +// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A +// non-empty dir is kept only if it actually holds an index.html, so a typo logs +// "API only" rather than serving 404s. +func resolveWebDir(dir string) string { + if dir == "" { + return "" + } + abs, err := filepath.Abs(dir) + if err != nil { + log.Printf("WARN --web-dir %q: %v; serving API only", dir, err) + return "" + } + if !statFile(filepath.Join(abs, "index.html")) { + log.Printf("WARN --web-dir %q has no index.html; serving API only", abs) + return "" + } + return abs +} + +type flagErr string + +func (e flagErr) Error() string { return string(e) } +func errFlag(s string) error { return flagErr("webgw: " + s) } + +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/cmd/webgw/register.go b/cmd/webgw/register.go new file mode 100644 index 0000000..ac45a5e --- /dev/null +++ b/cmd/webgw/register.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// registerReq is the POST /api/register body. It mirrors the bus contract exactly +// (token + the two PUBLIC key halves, each 64 hex chars). The private key never +// appears here — registration only publishes the public identity. The handle and +// role are NOT accepted from the client; they are fixed by the invite the token +// belongs to (no privilege escalation). +type registerReq struct { + Token string `json:"token"` + SignPub string `json:"sign_pub"` + KexPub string `json:"kex_pub"` +} + +// registerResp is what we return to the browser on success. The bus's /register +// (issue: user-accounts) decides handle/role from the invite; in mock mode the +// gateway echoes the configured pair so the SPA can greet the new user. +type registerResp struct { + Handle string `json:"handle"` + Role string `json:"role"` +} + +// registrar fulfils POST /api/register. It targets the bus's POST /register +// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint +// is rolled out, a built-in mock validates against a configured set of one-shot +// tokens so the whole wallet flow is testable locally. Mock tokens are checked +// first; anything else is proxied to the real bus when --register-url is set. +type registrar struct { + mu sync.Mutex + + registerURL string // bus POST /register; empty => mock-only + httpc *http.Client // for proxying to the bus + mockTokens map[string]*mockToken // configured one-shot invites for local testing +} + +// mockToken is a local stand-in for a bus invite: a token that maps to a fixed +// handle+role and can be consumed exactly once. +type mockToken struct { + handle string + role string + used bool +} + +// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2") +// and configures the optional proxy target. +func newRegistrar(registerURL, mockSpec string) *registrar { + r := ®istrar{ + registerURL: strings.TrimSpace(registerURL), + httpc: &http.Client{Timeout: 10 * time.Second}, + mockTokens: map[string]*mockToken{}, + } + for _, part := range strings.Split(mockSpec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + // tok=handle:role (role optional, defaults to member) + eq := strings.IndexByte(part, '=') + if eq < 0 { + continue + } + tok := strings.TrimSpace(part[:eq]) + hr := strings.TrimSpace(part[eq+1:]) + handle, role := hr, "member" + if c := strings.IndexByte(hr, ':'); c >= 0 { + handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:]) + } + if tok != "" && handle != "" { + r.mockTokens[tok] = &mockToken{handle: handle, role: role} + } + } + return r +} + +// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the +// startup log line). +func mockTokenCount(spec string) int { + n := 0 + for _, part := range strings.Split(spec, ",") { + if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') { + n++ + } + } + return n +} + +// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a +// 32-byte key). Both sign_pub and kex_pub are 32-byte keys. +func validHexKey(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} + +// handleRegister validates the keys and consumes the token. Order of resolution: +// 1. strict validation of the public keys (defends both mock and proxy paths); +// 2. mock token (one-shot) if configured; +// 3. proxy to the bus /register if --register-url is set; +// 4. otherwise reject with a clear error. +func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerReq + if !decode(w, r, &req) { + return + } + req.Token = strings.TrimSpace(req.Token) + if req.Token == "" { + writeErr(w, http.StatusBadRequest, "token required") + return + } + if !validHexKey(req.SignPub) { + writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)") + return + } + if !validHexKey(req.KexPub) { + writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)") + return + } + + reg := s.registrar + + // 2) mock one-shot token. + reg.mu.Lock() + mt, isMock := reg.mockTokens[req.Token] + if isMock { + if mt.used { + reg.mu.Unlock() + writeErr(w, http.StatusConflict, "invite already used") + return + } + mt.used = true + handle, role := mt.handle, mt.role + reg.mu.Unlock() + writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role}) + return + } + reg.mu.Unlock() + + // 3) proxy to the real bus /register when configured. + if reg.registerURL != "" { + s.proxyRegister(w, req) + return + } + + // 4) no mock match, no proxy target. + writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)") +} + +// proxyRegister forwards the registration to the bus's POST /register. The bus +// validates the invite (existence, not-used, not-expired) and adds the public +// identity to the allowlist with the invite's handle+role. This is unsigned by +// design: the TOKEN authorizes the call, not an admin signature. +func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) { + body, _ := json.Marshal(req) + resp, err := s.registrar.httpc.Post( + s.registrar.registerURL, + "application/json", + bytes.NewReader(body), + ) + if err != nil { + writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error()) + return + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + // On success, try to pass through the bus's handle/role if it returned them; + // otherwise a bare 201 is still success. + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + var rr registerResp + _ = json.Unmarshal(raw, &rr) + writeJSON(w, http.StatusCreated, rr) + return + } + // Forward the bus's error verbatim where possible. + msg := strings.TrimSpace(string(raw)) + if msg == "" { + msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode) + } + writeErr(w, resp.StatusCode, msg) +} diff --git a/cmd/webgw/server.go b/cmd/webgw/server.go new file mode 100644 index 0000000..2eb0d5a --- /dev/null +++ b/cmd/webgw/server.go @@ -0,0 +1,327 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// sessionCookie is the name of the gateway's session cookie. The browser sends +// it automatically on same-origin fetches AND on EventSource (SSE) connections — +// EventSource cannot set custom headers, so a cookie is the only way to +// authenticate the stream. It is HttpOnly so page JS can never read the token. +const sessionCookie = "unibus_session" + +// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an +// optional static file server for the built SPA. +// +// Two ways to get a session: +// - POST /api/session — the WALLET model. The browser hands its own bus +// identity (unlocked from its local encrypted key) and the gateway connects a +// dedicated bus client AS that user. Per-user, the primary path. +// - POST /api/login — the legacy operator passphrase. Binds the session to the +// single shared operator gateway. Kept for backward compatibility. +// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite +// token authorizes), it consumes a token and publishes the new user's PUBLIC +// identity to the bus allowlist. +type server struct { + operatorGW *gateway // shared operator client (legacy passphrase login) + busTemplate gatewayConfig // bus connection config; Identity is overridden per user session + registrar *registrar // POST /api/register backend (mock + proxy) + unlock string // passphrase that unlocks an operator session (constant-time compare) + webDir string // optional path to the built SPA (web/dist); empty = API only + mux *http.ServeMux + sessions *sessionStore +} + +func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server { + s := &server{ + operatorGW: operatorGW, + busTemplate: busTemplate, + registrar: registrar, + unlock: unlock, + webDir: webDir, + mux: http.NewServeMux(), + sessions: newSessionStore(), + } + s.routes() + return s +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } + +func (s *server) routes() { + // Liveness, unauthenticated (systemd / deploy smoke). + s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // Unauthenticated onboarding / auth routes. + s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes + s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity + s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase + + // Session-gated routes. + s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout)) + s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe)) + s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms)) + s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom)) + s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin)) + s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend)) + s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream)) + + // Everything else is the SPA (when --web-dir is set). Registered last. + if s.webDir != "" { + s.mux.Handle("/", s.spaHandler()) + } +} + +// meResp is the identity view returned by /api/session, /api/login and /api/me: +// the bus endpoint the session acts as, its signing public key, and the display +// handle. +type meResp struct { + Endpoint string `json:"endpoint"` + SignPub string `json:"sign_pub"` + Handle string `json:"handle"` +} + +// ---- auth ----------------------------------------------------------------- + +// auth wraps a handler so it runs only with a valid session cookie, resolving the +// session (and thus the per-user gateway) it belongs to. A missing or unknown +// token yields 401, which the SPA treats as "show the login screen". +func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return + } + sess, ok := s.sessions.get(c.Value) + if !ok { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return + } + next(w, r, sess) + } +} + +// handleLogin is the legacy operator passphrase login: it unlocks a session bound +// to the shared operator gateway. The wallet path (POST /api/session) is +// preferred; this remains for backward compatibility with the single-operator MVP. +func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Passphrase string `json:"passphrase"` + } + if !decode(w, r, &req) { + return + } + // Constant-time compare so a wrong passphrase cannot be timed character by + // character. An empty configured passphrase never matches. + if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 { + writeErr(w, http.StatusUnauthorized, "wrong passphrase") + return + } + tok := newToken() + handle := s.operatorGW.endpoint + if len(handle) > 8 { + handle = handle[:8] + } + s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()}) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: tok, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle}) +} + +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) { + if c, err := r.Cookie(sessionCookie); err == nil { + if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil { + // Per-user session: tear down its bus client so the private key and the + // NATS connection do not outlive the session. + _ = sess.gw.Close() + } + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"}) +} + +func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) { + writeJSON(w, http.StatusOK, meResp{ + Endpoint: sess.gw.endpoint, + SignPub: hex.EncodeToString(sess.gw.id.SignPub), + Handle: sess.handle, + }) +} + +// ---- rooms ---------------------------------------------------------------- + +func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) { + rooms, err := sess.gw.listRooms() + if err != nil { + writeErr(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, rooms) +} + +func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) { + var req createRoomReq + if !decode(w, r, &req) { + return + } + rv, err := sess.gw.createRoom(req) + if err != nil { + writeErr(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusCreated, rv) +} + +func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) { + if err := sess.gw.join(r.PathValue("id")); err != nil { + writeErr(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "joined"}) +} + +func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) { + var req sendReq + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Body) == "" { + writeErr(w, http.StatusBadRequest, "body required") + return + } + if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil { + writeErr(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "sent"}) +} + +// handleStream is the SSE endpoint: it joins the room, attaches to the session's +// fan-out hub, and streams each decrypted message as a `data:` event. For a +// persisted room the hub's underlying subscription delivers history first +// (scrollback) and then live messages; for an ephemeral room only live messages +// flow. The stream ends when the browser disconnects (ctx cancelled). +func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) { + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, http.StatusInternalServerError, "streaming unsupported") + return + } + ch, cleanup, err := sess.gw.openStream(r.PathValue("id")) + if err != nil { + writeErr(w, http.StatusBadGateway, err.Error()) + return + } + defer cleanup() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy) + w.WriteHeader(http.StatusOK) + // An initial comment opens the stream immediately so the browser's + // EventSource fires `onopen` without waiting for the first message. + _, _ = w.Write([]byte(": connected\n\n")) + flusher.Flush() + + ctx := r.Context() + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ping.C: + // Comment line keeps idle proxies from closing the connection. + if _, err := w.Write([]byte(": ping\n\n")); err != nil { + return + } + flusher.Flush() + case m := <-ch: + b, err := json.Marshal(m) + if err != nil { + continue + } + if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil { + return + } + flusher.Flush() + } + } +} + +// ---- SPA serving (optional) ----------------------------------------------- + +// spaHandler serves the built SPA from s.webDir. A request for an existing asset +// is served directly; any other path (a client-side route) falls back to +// index.html so the SPA router can take over. /api and /healthz are matched first. +func (s *server) spaHandler() http.Handler { + root := http.Dir(s.webDir) + fileServer := http.FileServer(root) + index := filepath.Join(s.webDir, "index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + http.ServeFile(w, r, index) + return + } + if f, err := root.Open(p); err == nil { + _ = f.Close() + fileServer.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, index) // unknown path -> SPA client-side routing + }) +} + +// ---- helpers -------------------------------------------------------------- + +func newToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// decode reads a JSON body into v, writing a 400 and returning false on failure. +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + defer r.Body.Close() + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil { + writeErr(w, http.StatusBadRequest, "bad json: "+err.Error()) + return false + } + return true +} + +// statFile reports whether path exists and is a regular file (used to validate +// --web-dir at startup so a typo surfaces as a clear log line, not 404s later). +func statFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +} diff --git a/cmd/webgw/session.go b/cmd/webgw/session.go new file mode 100644 index 0000000..2583d2b --- /dev/null +++ b/cmd/webgw/session.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/hex" + "fmt" + "net/http" + "sync" + "time" + + cs "fn-registry/functions/cybersecurity" +) + +// session is one logged-in browser. In the wallet model each session carries the +// user's OWN bus identity: the browser unlocks its locally-encrypted private key +// and hands the full keypair to the gateway over TLS, and the gateway spins up a +// dedicated bus client (a *gateway) that acts AS that user. The private key lives +// only in this process's memory for the life of the session — it is never written +// to disk and is dropped when the session ends. +// +// A session may instead point at the shared operator gateway (the legacy +// passphrase login); `owned` distinguishes the two so logout only closes the bus +// client it created. +type session struct { + gw *gateway + owned bool // true => gw was built for this session and must be Closed on logout + handle string + issuedAt time.Time +} + +// sessionStore is the gateway's set of live browser sessions, keyed by the opaque +// cookie token. It is independent of any single bus identity. +type sessionStore struct { + mu sync.Mutex + m map[string]*session +} + +func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} } + +func (st *sessionStore) put(token string, s *session) { + st.mu.Lock() + st.m[token] = s + st.mu.Unlock() +} + +func (st *sessionStore) get(token string) (*session, bool) { + st.mu.Lock() + defer st.mu.Unlock() + s, ok := st.m[token] + return s, ok +} + +// drop removes a session and returns it so the caller can close an owned gateway. +func (st *sessionStore) drop(token string) (*session, bool) { + st.mu.Lock() + defer st.mu.Unlock() + s, ok := st.m[token] + if ok { + delete(st.m, token) + } + return s, ok +} + +// closeAll closes every owned per-user gateway (used at shutdown). The shared +// operator gateway is owned by main and closed separately. +func (st *sessionStore) closeAll() { + st.mu.Lock() + defer st.mu.Unlock() + for tok, s := range st.m { + if s.owned && s.gw != nil { + _ = s.gw.Close() + } + delete(st.m, tok) + } +} + +// identityFromHex builds a cs.Identity from the four hex halves the browser sends +// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv +// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built +// identity that fails opaquely deep in the bus client. +func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) { + sp, err := hex.DecodeString(signPub) + if err != nil { + return cs.Identity{}, fmt.Errorf("sign_pub: %w", err) + } + spriv, err := hex.DecodeString(signPriv) + if err != nil { + return cs.Identity{}, fmt.Errorf("sign_priv: %w", err) + } + kp, err := hex.DecodeString(kexPub) + if err != nil { + return cs.Identity{}, fmt.Errorf("kex_pub: %w", err) + } + kpriv, err := hex.DecodeString(kexPriv) + if err != nil { + return cs.Identity{}, fmt.Errorf("kex_priv: %w", err) + } + if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 { + return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)", + len(sp), len(spriv), len(kp), len(kpriv)) + } + return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil +} + +// sessionReq is the POST /api/session body: the user's full wallet identity (hex) +// plus a display handle. The private halves arrive only over TLS and are held in +// memory for the session; they are never persisted server-side. +type sessionReq struct { + Handle string `json:"handle"` + SignPub string `json:"sign_pub"` + SignPriv string `json:"sign_priv"` + KexPub string `json:"kex_pub"` + KexPriv string `json:"kex_priv"` +} + +// handleSession opens a per-user session. It builds the user's bus identity from +// the posted keypair, connects a dedicated bus client as that user, and issues a +// session cookie bound to it. This is the wallet-model replacement for the +// operator passphrase login. +func (s *server) handleSession(w http.ResponseWriter, r *http.Request) { + var req sessionReq + if !decode(w, r, &req) { + return + } + id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error()) + return + } + cfg := s.busTemplate + cfg.Identity = id + gw, err := newGateway(cfg) + if err != nil { + writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error()) + return + } + tok := newToken() + s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()}) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: tok, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle}) +} diff --git a/cmd/webgw/webgw_test.go b/cmd/webgw/webgw_test.go new file mode 100644 index 0000000..ca3df95 --- /dev/null +++ b/cmd/webgw/webgw_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +// fixed wallet vector derived in the browser from the mnemonic +// "legal winner thank year wave sausage worth useful legal winner thank yellow" +// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go +// side accepts the browser-derived key sizes. +const ( + fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db" + fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db" + fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257" + fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2" +) + +func TestIdentityFromHex(t *testing.T) { + id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv) + if err != nil { + t.Fatalf("identityFromHex valid vector: %v", err) + } + if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 { + t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv)) + } + + // Wrong sign_priv size (32 instead of 64) must be rejected. + if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil { + t.Fatalf("expected error for short sign_priv") + } + // Non-hex must be rejected. + if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil { + t.Fatalf("expected error for non-hex sign_pub") + } +} + +func TestValidHexKey(t *testing.T) { + if !validHexKey(fixSignPub) { + t.Fatalf("fixSignPub should be a valid 32-byte hex key") + } + if validHexKey("abcd") { + t.Fatalf("short key should be invalid") + } + if validHexKey(strings.Repeat("z", 64)) { + t.Fatalf("non-hex key should be invalid") + } +} + +func TestNewRegistrarParsesMockTokens(t *testing.T) { + r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin") + if len(r.mockTokens) != 3 { + t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens)) + } + if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" { + t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"]) + } + if r.mockTokens["bob"].role != "member" { + t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role) + } + if r.mockTokens["alice"].role != "admin" { + t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role) + } +} + +// post builds a server with only a registrar (the register path does not touch a +// gateway) and runs one POST /api/register, returning status + decoded body. +func postRegister(t *testing.T, s *server, body string) (int, map[string]string) { + t.Helper() + req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body)) + w := httptest.NewRecorder() + s.handleRegister(w, req) + var m map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &m) + return w.Code, m +} + +func TestHandleRegisterMockSingleUse(t *testing.T) { + s := &server{registrar: newRegistrar("", "demo=demo:member")} + + // 1) valid token + valid keys => 201 with the invite's handle/role. + code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`) + if code != 201 { + t.Fatalf("first register: want 201, got %d (%v)", code, body) + } + if body["handle"] != "demo" || body["role"] != "member" { + t.Fatalf("first register body: %v", body) + } + + // 2) same token again => 409 (single-use consumed). + code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`) + if code != 409 { + t.Fatalf("reused token: want 409, got %d", code) + } +} + +func TestHandleRegisterValidation(t *testing.T) { + s := &server{registrar: newRegistrar("", "demo=demo:member")} + + // bad sign_pub (too short) => 400 + if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 { + t.Fatalf("short sign_pub: want 400, got %d", code) + } + // missing token => 400 + if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 { + t.Fatalf("missing token: want 400, got %d", code) + } + // unknown token with no mock match and no register-url => 400 + if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 { + t.Fatalf("unknown token: want 400, got %d", code) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1439de0 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module github.com/enmanuel/uniweb + +go 1.26.4 + +replace fn-registry => ../../../../ + +replace github.com/enmanuel/unibus => ../unibus + +require ( + fn-registry v0.0.0-00010101000000-000000000000 + github.com/enmanuel/unibus v0.0.0-00010101000000-000000000000 + github.com/oklog/ulid/v2 v2.1.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nats-server/v2 v2.11.15 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/time v0.15.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f761849 --- /dev/null +++ b/go.sum @@ -0,0 +1,77 @@ +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0= +github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..de9ca1d --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.local +.vite/ +*.tsbuildinfo diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1020694 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + unibus + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..79b6978 --- /dev/null +++ b/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "unibus-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/bip39": "^2.2.0", + "@tabler/icons-react": "^3.36.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.3.4", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "typescript": "~5.6.3", + "vite": "^6.0.3" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..0685fcf --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1517 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.0(react@19.2.7) + '@noble/curves': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 + '@scure/bip39': + specifier: ^2.2.0 + version: 2.2.0 + '@tabler/icons-react': + specifier: ^3.36.0 + version: 3.44.0(react@19.2.7) + react: + specifier: ^19.2.0 + version: 19.2.7 + react-dom: + specifier: ^19.2.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) + postcss: + specifier: ^8.4.49 + version: 8.5.15 + postcss-preset-mantine: + specifier: ^1.17.0 + version: 1.18.0(postcss@8.5.15) + postcss-simple-vars: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.5.15) + typescript: + specifier: ~5.6.3 + version: 5.6.3 + vite: + specifier: ^6.0.3 + version: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mantine/core@9.3.0': + resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} + peerDependencies: + '@mantine/hooks': 9.3.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + '@mantine/hooks@9.3.0': + resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} + peerDependencies: + react: ^19.2.0 + + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + + '@scure/bip39@2.2.0': + resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} + + '@tabler/icons-react@3.44.0': + resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.44.0': + resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.34: + resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001797: + resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + electron-to-chromium@1.5.368: + resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-mixins@12.1.2: + resolution: {integrity: sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==} + engines: {node: ^20.0 || ^22.0 || >=24.0} + peerDependencies: + postcss: ^8.2.14 + + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-preset-mantine@1.18.0: + resolution: {integrity: sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==} + peerDependencies: + postcss: '>=8.0.0' + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-simple-vars@7.0.1: + resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} + engines: {node: '>=14.0'} + peerDependencies: + postcss: ^8.2.1 + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sugarss@5.0.1: + resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.3.3 + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + type-fest: 5.7.0 + transitivePeerDependencies: + - '@types/react' + + '@mantine/hooks@9.3.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@2.2.0': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@scure/base@2.2.0': {} + + '@scure/bip39@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@scure/base': 2.2.0 + + '@tabler/icons-react@3.44.0(react@19.2.7)': + dependencies: + '@tabler/icons': 3.44.0 + react: 19.2.7 + + '@tabler/icons@3.44.0': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.34: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.34 + caniuse-lite: 1.0.30001797 + electron-to-chromium: 1.5.368 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001797: {} + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-node-es@1.1.0: {} + + electron-to-chromium@1.5.368: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.47: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-mixins@12.1.2(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-js: 4.1.0(postcss@8.5.15) + postcss-simple-vars: 7.0.1(postcss@8.5.15) + sugarss: 5.0.1(postcss@8.5.15) + tinyglobby: 0.2.17 + + postcss-nested@7.0.2(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-preset-mantine@1.18.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-mixins: 12.1.2(postcss@8.5.15) + postcss-nested: 7.0.2(postcss@8.5.15) + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-simple-vars@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.7: {} + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + sugarss@5.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + tabbable@6.4.0: {} + + tagged-tag@1.0.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.6.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + util-deprecate@1.0.2: {} + + vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + sugarss: 5.0.1(postcss@8.5.15) + + yallist@3.1.1: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..e817f56 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..5c8e799 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { Center, Loader } from "@mantine/core"; +import { ChatShell } from "./ChatShell"; +import { Join } from "./Join"; +import { Recover } from "./Recover"; +import { WalletLogin } from "./WalletLogin"; +import { Welcome } from "./Welcome"; +import { api } from "./api"; +import { localIdentity } from "./wallet/account"; +import type { User } from "./types"; + +type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat"; + +// readJoinToken returns the invite token if the current URL is /join?token=XXX. +function readJoinToken(): string | null { + if (window.location.pathname !== "/join") return null; + return new URLSearchParams(window.location.search).get("token"); +} + +// clearUrl drops any /join?token from the address bar once consumed, so a refresh +// or a shared screenshot does not replay the (single-use) token. +function clearUrl() { + if (window.location.pathname !== "/") { + window.history.replaceState(null, "", "/"); + } +} + +export function App() { + const [route, setRoute] = useState("loading"); + const [user, setUser] = useState(null); + const [token, setToken] = useState(""); + const [storedHandle, setStoredHandle] = useState(""); + + // Decide the entry screen on mount: an invite link goes straight to join; a live + // gateway session resumes the chat; a device with a stored identity shows the + // password unlock; an empty device shows the welcome chooser. + useEffect(() => { + const t = readJoinToken(); + if (t) { + setToken(t); + setRoute("join"); + return; + } + let cancelled = false; + (async () => { + try { + const me = await api.me(); + if (cancelled) return; + setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) }); + setRoute("chat"); + return; + } catch { + // no live session — fall through + } + const stored = await localIdentity(); + if (cancelled) return; + if (stored) { + setStoredHandle(stored.handle); + setRoute("login"); + } else { + setRoute("welcome"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const enterChat = (u: User) => { + setUser(u); + setRoute("chat"); + clearUrl(); + }; + + const logout = () => { + void api.logout().catch(() => {}); + setUser(null); + // Keep the encrypted identity on the device: logging out returns to the + // password unlock, not a full reset. + void localIdentity().then((stored) => { + if (stored) { + setStoredHandle(stored.handle); + setRoute("login"); + } else { + setRoute("welcome"); + } + }); + }; + + switch (route) { + case "loading": + return ( +
+ +
+ ); + case "join": + return ( + setRoute("recover")} + /> + ); + case "welcome": + return ( + { + setToken(t); + setRoute("join"); + }} + onRecover={() => setRoute("recover")} + /> + ); + case "login": + return ( + setRoute("recover")} + /> + ); + case "recover": + return ( + setRoute(storedHandle ? "login" : "welcome")} + /> + ); + case "chat": + return user ? ( + + ) : ( +
+ +
+ ); + } +} diff --git a/web/src/AuthShell.tsx b/web/src/AuthShell.tsx new file mode 100644 index 0000000..6f772fb --- /dev/null +++ b/web/src/AuthShell.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { Card, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; + +// AuthCard is the shared centered card used by every pre-chat screen (welcome, +// join, recover, wallet login) so they all look like one flow. +export function AuthCard({ + width = 460, + children, +}: { + width?: number; + children: ReactNode; +}) { + return ( +
+ + {children} + +
+ ); +} + +// AuthHeader is the icon + title + subtitle block at the top of an auth card. +export function AuthHeader({ + icon, + title, + subtitle, +}: { + icon: ReactNode; + title: string; + subtitle?: string; +}) { + return ( + + + {icon} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + ); +} diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx new file mode 100644 index 0000000..8e669b5 --- /dev/null +++ b/web/src/ChatPanel.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import { + ActionIcon, + Avatar, + Box, + Center, + Divider, + Group, + ScrollArea, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { + IconSend, + IconLock, + IconHash, + IconDotsVertical, + IconPaperclip, +} from "@tabler/icons-react"; +import { api, streamRoom } from "./api"; +import type { Message, Room } from "./types"; + +function initials(s: string) { + return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?"; +} +function timeShort(ts: number) { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2, "0")}:${String( + d.getMinutes(), + ).padStart(2, "0")}`; +} + +function MessageRow({ msg }: { msg: Message }) { + return ( + + + {initials(msg.sender)} + + + + + {msg.sender} + + + {timeShort(msg.ts)} + + + + {msg.body} + + + + ); +} + +export function ChatPanel({ room }: { room: Room | undefined }) { + const [draft, setDraft] = useState(""); + const [messages, setMessages] = useState([]); + const [sendError, setSendError] = useState(null); + const viewport = useRef(null); + + // Abre el stream SSE de la room activa. El gateway entrega historia (rooms + // persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque + // un re-render no debe duplicar y el eco del propio envío llega por aquí. + useEffect(() => { + setMessages([]); + setSendError(null); + if (!room) return; + const close = streamRoom(room.id, (m) => { + setMessages((prev) => + prev.some((p) => p.id === m.id) ? prev : [...prev, m], + ); + }); + return close; + }, [room?.id]); + + useEffect(() => { + viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); + }, [room?.id, messages.length]); + + if (!room) { + return ( +
+ Selecciona una conversación +
+ ); + } + + const send = async () => { + const body = draft.trim(); + if (!body) return; + setDraft(""); + setSendError(null); + try { + // No optimista: el mensaje propio vuelve por SSE con su id real (mine:true), + // evitando duplicados. + await api.send(room.id, body); + } catch (e) { + setDraft(body); // restaura el borrador si el envío falló + setSendError(e instanceof Error ? e.message : "No se pudo enviar"); + } + }; + + return ( + + + + + {initials(room.name)} + + + + + {room.name} + + {room.encrypted ? ( + + + + ) : ( + + )} + + + {room.encrypted ? "cifrada · E2E" : "abierta · cleartext"} + + + + + + + + + + + + {messages.map((m) => ( + + ))} + + + + + {sendError && ( + + {sendError} + + )} + + + + + setDraft(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && void send()} + /> + void send()} + disabled={!draft.trim()} + > + + + + + ); +} diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx new file mode 100644 index 0000000..550c652 --- /dev/null +++ b/web/src/ChatShell.tsx @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from "react"; +import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core"; +import { Sidebar } from "./Sidebar"; +import { ChatPanel } from "./ChatPanel"; +import { api } from "./api"; +import type { Room, User } from "./types"; + +export function ChatShell({ + user, + onLogout, +}: { + user: User; + onLogout: () => void; +}) { + const [rooms, setRooms] = useState([]); + const [activeId, setActiveId] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(() => { + setLoading(true); + api + .listRooms() + .then((rs) => { + setRooms(rs); + setActiveId((cur) => cur || rs[0]?.id || ""); + setError(null); + }) + .catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const active = rooms.find((r) => r.id === activeId); + + // El panel derecho muestra el estado de carga/error/empty sin tocar el layout. + let panel = ; + if (loading && rooms.length === 0) { + panel = ( +
+ +
+ ); + } else if (error) { + panel = ( +
+ + + {error} + + + +
+ ); + } else if (rooms.length === 0) { + panel = ( +
+ No perteneces a ninguna room todavía +
+ ); + } + + return ( + + + + + + {panel} + + + ); +} diff --git a/web/src/Join.tsx b/web/src/Join.tsx new file mode 100644 index 0000000..cab85ed --- /dev/null +++ b/web/src/Join.tsx @@ -0,0 +1,322 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Card, + Center, + Checkbox, + CopyButton, + Group, + Loader, + PasswordInput, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconCheck, + IconCopy, + IconKey, + IconShieldLock, +} from "@tabler/icons-react"; +import { api, ApiError } from "./api"; +import { AuthCard, AuthHeader } from "./AuthShell"; +import type { User } from "./types"; +import { newMnemonic, mnemonicWords } from "./wallet/bip39"; +import { deriveIdentity, type WalletIdentity } from "./wallet/derive"; +import { saveAndOpen } from "./wallet/account"; + +type Step = "generating" | "show-seed" | "confirm-seed" | "password" | "joining"; + +// pickPositions chooses `count` distinct word positions (0-based) to ask the user +// to confirm. This is a UI choice, not key material, so Math.random is fine. +function pickPositions(total: number, count: number): number[] { + const all = Array.from({ length: total }, (_, i) => i); + for (let i = all.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [all[i], all[j]] = [all[j], all[i]]; + } + return all.slice(0, count).sort((a, b) => a - b); +} + +// Join is the onboarding page reached from an invite link (/join?token=XXX). It +// generates a brand-new BIP39 seed, derives the identity, shows the seed exactly +// once with a confirmation gate, takes a local password, registers the PUBLIC key +// with the bus using the token, and enters the chat. The seed is never persisted +// and never sent to the server. +export function Join({ + token, + onJoined, + onRecover, +}: { + token: string; + onJoined: (u: User) => void; + onRecover: () => void; +}) { + const [step, setStep] = useState("generating"); + const [mnemonic, setMnemonic] = useState(""); + const [identity, setIdentity] = useState(null); + const [error, setError] = useState(null); + + // Generate the seed + identity once on mount. Deriving is fast and pure. + useEffect(() => { + if (!token) { + setError("Enlace de invitación inválido: falta el token."); + return; + } + try { + const m = newMnemonic(); + setMnemonic(m); + setIdentity(deriveIdentity(m)); + setStep("show-seed"); + } catch { + setError("No se pudo generar la identidad en este navegador."); + } + }, [token]); + + const words = useMemo(() => mnemonicWords(mnemonic), [mnemonic]); + + if (error && step === "generating") { + return ( + + } title="Error"> + {error} + + + + ); + } + + if (step === "generating" || !identity) { + return ( +
+ +
+ ); + } + + if (step === "show-seed") { + return ( + setStep("confirm-seed")} /> + ); + } + + if (step === "confirm-seed") { + return ( + setStep("show-seed")} + onConfirmed={() => setStep("password")} + /> + ); + } + + // step === "password" | "joining" + return ( + { + setStep("joining"); + setError(null); + try { + // Register the PUBLIC identity with the bus (token authorizes), then + // encrypt the private key locally and open the per-user session. + const res = await api.register(token, identity.signPub, identity.kexPub); + const user = await saveAndOpen(identity, res.handle, password); + onJoined(user); + } catch (e) { + setError( + e instanceof ApiError ? e.message : "No se pudo completar el alta.", + ); + setStep("password"); + } + }} + /> + ); +} + +// ---- sub-screens ---------------------------------------------------------- + +function ShowSeed({ + words, + onContinue, +}: { + words: string[]; + onContinue: () => void; +}) { + const [acknowledged, setAcknowledged] = useState(false); + const phrase = words.join(" "); + return ( + + } + title="Guarda tu frase de recuperación" + subtitle="Estas 12 palabras son tu ÚNICA forma de recuperar tu cuenta si olvidas la contraseña o cambias de dispositivo. No las compartas con nadie." + /> + + + {words.map((w, i) => ( + + + {i + 1} + + + {w} + + + ))} + + + + + {({ copied, copy }) => ( + + )} + + + }> + unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo + el administrador podrá darte de alta de nuevo. + + setAcknowledged(e.currentTarget.checked)} + label="He guardado mi frase de recuperación en un lugar seguro" + /> + + + ); +} + +function ConfirmSeed({ + words, + onBack, + onConfirmed, +}: { + words: string[]; + onBack: () => void; + onConfirmed: () => void; +}) { + // Ask the user to re-type 3 random words from their phrase. This proves they + // actually wrote the seed down rather than clicking through. + const positions = useMemo(() => pickPositions(words.length, 3), [words.length]); + const [inputs, setInputs] = useState>({}); + const allCorrect = positions.every( + (p) => (inputs[p] ?? "").trim().toLowerCase() === words[p], + ); + const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0); + return ( + + } + title="Confirma tu frase" + subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien." + /> + + {positions.map((p) => ( + 0 && + (inputs[p] ?? "").trim().toLowerCase() !== words[p] + ? "No coincide" + : undefined + } + onChange={(e) => { + // Capture the value synchronously: React nulls e.currentTarget + // after dispatch, so reading it inside the state updater (which runs + // later) would throw "Cannot read properties of null". + const v = e.currentTarget.value; + setInputs((prev) => ({ ...prev, [p]: v })); + }} + autoComplete="off" + spellCheck={false} + /> + ))} + + {!allCorrect && anyTyped && ( + + Revisa el orden y la ortografía de las palabras. + + )} + + + + + + ); +} + +function SetPassword({ + busy, + error, + onSubmit, +}: { + busy: boolean; + error: string | null; + onSubmit: (password: string) => void; +}) { + const [pw, setPw] = useState(""); + const [pw2, setPw2] = useState(""); + const tooShort = pw.length > 0 && pw.length < 8; + const mismatch = pw2.length > 0 && pw !== pw2; + const ready = pw.length >= 8 && pw === pw2 && !busy; + return ( + + } + title="Protege tu identidad" + subtitle="Elige una contraseña para cifrar tu clave en ESTE dispositivo. No se guarda ni se envía a ningún servidor; solo desbloquea tu clave local." + /> + } + value={pw} + error={tooShort ? "Demasiado corta" : undefined} + onChange={(e) => setPw(e.currentTarget.value)} + data-autofocus + /> + } + value={pw2} + error={mismatch ? "No coincide" : undefined} + onChange={(e) => setPw2(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)} + /> + {error && ( + + {error} + + )} + + + ); +} diff --git a/web/src/Login.tsx b/web/src/Login.tsx new file mode 100644 index 0000000..731bfe6 --- /dev/null +++ b/web/src/Login.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { + Button, + Card, + Center, + PasswordInput, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { IconShieldLock, IconKey } from "@tabler/icons-react"; +import { api, ApiError } from "./api"; +import type { User } from "./types"; + +export function Login({ onLogin }: { onLogin: (u: User) => void }) { + const [handle, setHandle] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const ready = handle.trim().length > 0 && password.length > 0; + const connect = async () => { + if (!ready || busy) return; + setBusy(true); + setError(null); + try { + // La contraseña desbloquea la sesión del gateway (passphrase del operador). + // El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2). + const me = await api.login(password); + const h = handle.trim() || me.endpoint.slice(0, 8); + onLogin({ id: me.endpoint, handle: h }); + } catch (e) { + setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway"); + setBusy(false); + } + }; + + return ( +
+ + + + + + + unibus + + Mensajería cifrada de extremo a extremo + + + setHandle(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && connect()} + data-autofocus + /> + } + value={password} + onChange={(e) => setPassword(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && void connect()} + /> + {error && ( + + {error} + + )} + + + +
+ ); +} diff --git a/web/src/Recover.tsx b/web/src/Recover.tsx new file mode 100644 index 0000000..991de7d --- /dev/null +++ b/web/src/Recover.tsx @@ -0,0 +1,175 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Anchor, + Button, + Code, + Group, + PasswordInput, + Stack, + Text, + Textarea, + TextInput, +} from "@mantine/core"; +import { IconKey, IconRotateClockwise } from "@tabler/icons-react"; +import { AuthCard, AuthHeader } from "./AuthShell"; +import { ApiError } from "./api"; +import type { User } from "./types"; +import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39"; +import { deriveIdentity } from "./wallet/derive"; +import { saveAndOpen } from "./wallet/account"; + +type Step = "phrase" | "password"; + +// Recover re-creates an existing identity from its 12-word seed — no admin needed. +// Validating the BIP39 phrase and re-deriving yields the SAME keypair (same +// sign_pub) the bus already authorizes, so the user lands back in the allowlist +// with their place intact. A new local password then re-encrypts the key on this +// device. Only if the user loses BOTH the password AND the seed must the admin +// re-provision them. +export function Recover({ + onRecovered, + onBack, +}: { + onRecovered: (u: User) => void; + onBack: () => void; +}) { + const [step, setStep] = useState("phrase"); + const [phrase, setPhrase] = useState(""); + const [handle, setHandle] = useState(""); + const [pw, setPw] = useState(""); + const [pw2, setPw2] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const normalized = normalizeMnemonic(phrase); + const wordCount = mnemonicWords(phrase).length; + const valid = isValidMnemonic(phrase); + + // Re-derive as soon as the phrase is valid, so we can show the user which + // identity (sign_pub) it maps to before they commit a new password. + const identity = useMemo( + () => (valid ? deriveIdentity(normalized) : null), + [valid, normalized], + ); + + if (step === "phrase") { + return ( + + } + title="Recuperar con tu frase" + subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor." + /> +