From a791232f1845bad4895b6381709cc996522c5732 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 09:28:59 +0100 Subject: [PATCH] [feat](web): serve the SPA and web actions on the single listener Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/, an embedded placeholder bundle, the /web/* auth and action handlers, and the explicit browser-action allowlist. The SPA and the API share the one plaintext :8080 listener; there is no second port and no peer edge, so main.go also drops the mTLS listener and the identity mounts in the debug compose fall away. /_manifest announces the web block only while the SPA is mounted (honesty rule), always with the single service port. The DSNs move to the shared database's scram-sha-256 credentials, matching rev 9's removal of the PKI layer. Committed web/bun.lock because web/package.json and vite.config.ts are new in this commit. src/web/dist/index.html is a hand-written placeholder, not a generated bundle: it keeps bare `go build`/`go vet` working from a clean checkout, while /web/dist (the real build output) stays gitignored. --- .gitignore | 16 + Dockerfile | 19 +- docker-compose.debug.yaml | 29 +- docker-compose.yaml | 12 +- src/handlers/manifest/manifest.go | 57 ++- src/handlers/manifest/manifest_test.go | 70 +++ src/handlers/web/action.go | 82 ++++ src/handlers/web/auth.go | 167 ++++++++ src/handlers/web/config.go | 120 ++++++ src/handlers/web/cookies.go | 62 +++ src/handlers/web/hub.go | 66 +++ src/handlers/web/json.go | 48 +++ src/handlers/web/listener.go | 84 ++++ src/handlers/web/middleware.go | 121 ++++++ src/handlers/web/spa.go | 144 +++++++ src/handlers/web/web_test.go | 254 +++++++++++ src/main.go | 117 +++-- src/web/dist/index.html | 19 + src/web_assets.go | 21 + web/.gitignore | 11 + web/bun.lock | 377 ++++++++++++++++ web/index.html | 14 + web/package.json | 22 + web/src/components/Banner.tsx | 15 + web/src/components/ConfirmDialog.tsx | 110 +++++ web/src/components/Shell.tsx | 180 ++++++++ web/src/components/Spinner.tsx | 12 + web/src/components/TagInput.tsx | 85 ++++ web/src/index.css | 524 +++++++++++++++++++++++ web/src/lib/api.ts | 239 +++++++++++ web/src/lib/auth.ts | 110 +++++ web/src/lib/drafts.ts | 27 ++ web/src/lib/hooks.ts | 22 + web/src/lib/relativeTime.ts | 48 +++ web/src/lib/storage.ts | 55 +++ web/src/lib/theme.ts | 28 ++ web/src/main.tsx | 140 ++++++ web/src/router.tsx | 112 +++++ web/src/screens/Editor.tsx | 566 +++++++++++++++++++++++++ web/src/screens/Login.tsx | 266 ++++++++++++ web/src/screens/NotesList.tsx | 282 ++++++++++++ web/src/screens/Settings.tsx | 188 ++++++++ web/src/state/store.ts | 435 +++++++++++++++++++ web/src/types.ts | 43 ++ web/tsconfig.json | 19 + web/vite.config.ts | 15 + 46 files changed, 5392 insertions(+), 61 deletions(-) create mode 100644 src/handlers/manifest/manifest_test.go create mode 100644 src/handlers/web/action.go create mode 100644 src/handlers/web/auth.go create mode 100644 src/handlers/web/config.go create mode 100644 src/handlers/web/cookies.go create mode 100644 src/handlers/web/hub.go create mode 100644 src/handlers/web/json.go create mode 100644 src/handlers/web/listener.go create mode 100644 src/handlers/web/middleware.go create mode 100644 src/handlers/web/spa.go create mode 100644 src/handlers/web/web_test.go create mode 100644 src/web/dist/index.html create mode 100644 src/web_assets.go create mode 100644 web/.gitignore create mode 100644 web/bun.lock create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/components/Banner.tsx create mode 100644 web/src/components/ConfirmDialog.tsx create mode 100644 web/src/components/Shell.tsx create mode 100644 web/src/components/Spinner.tsx create mode 100644 web/src/components/TagInput.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/drafts.ts create mode 100644 web/src/lib/hooks.ts create mode 100644 web/src/lib/relativeTime.ts create mode 100644 web/src/lib/storage.ts create mode 100644 web/src/lib/theme.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/router.tsx create mode 100644 web/src/screens/Editor.tsx create mode 100644 web/src/screens/Login.tsx create mode 100644 web/src/screens/NotesList.tsx create mode 100644 web/src/screens/Settings.tsx create mode 100644 web/src/state/store.ts create mode 100644 web/src/types.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index df5d535..857402c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,19 @@ src/tmp/ src/src/ src/logs/ + +# Frontend build output and dependencies (the committed placeholder at +# src/web/dist/index.html is deliberately NOT ignored — it keeps bare +# `go build`/`go vet` working from a clean checkout). Patterns are anchored to +# the repo root so `src/web/dist/` stays tracked. +/web/dist/ +/web/node_modules/ +/web/.bun/ +/web/.buntmp/ + +# Go build cache (sandbox: default GOCACHE is read-only) +/.gocache/ +/.gotmp/ +/.docker-config/ +/.vision/ +/.vision/ diff --git a/Dockerfile b/Dockerfile index acf3d6a..62b362d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +# --- Web build stage (browser-edge SPA, contract §10.2) --- +# Produces /web/dist, copied into the Go build context as web/dist so the +# //go:embed all:web/dist directive in src/web_assets.go picks it up. +FROM oven/bun:1.3.9-alpine AS web-builder +WORKDIR /web +COPY web/package.json web/bun.lock ./ +RUN bun ci +COPY web/ ./ +RUN bun run build + # --- Build Stage --- FROM golang:1.26 AS builder WORKDIR /app @@ -13,8 +23,11 @@ COPY src/go.mod src/go.sum ./ RUN go mod download COPY src/ ./ +COPY --from=web-builder /web/dist ./web/dist -RUN CGO_ENABLED=0 GOOS=linux go build -o server main.go +# Build the whole main package (not just main.go): the browser-edge SPA embed +# lives in web_assets.go, so a single-file build would not see embeddedWeb. +RUN CGO_ENABLED=0 GOOS=linux go build -o server . # --- Run Stage --- FROM alpine:latest @@ -30,4 +43,8 @@ LABEL org.opencontainers.image.revision=$IMAGE_REVISION COPY --from=builder /app/server . +# Contract rev 9 §3.3: exactly ONE plaintext listener (:8080) serves the API +# and the embedded browser web app (SPA + /web/*). No second port, no PKI. +EXPOSE 8080 + CMD ./server diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml index 32eef13..4a7256c 100644 --- a/docker-compose.debug.yaml +++ b/docker-compose.debug.yaml @@ -44,12 +44,10 @@ services: - go-mod-cache:/go/pkg/mod - go-build-cache:/root/.cache/go-build - /app/src/tmp # Exclude Air's tmp dir from syncing - # mTLS identity for the auth edge: the certs Maestro issued for the dev - # fleet (devboot, DCNTRLIZED_DEV=true) at the shared-module default paths, - # so the authproxy client authenticates to the mTLS auth hub. - - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/ca.crt:/dcntrlized/pki/ca.crt:ro - - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.crt:/dcntrlized/pki/tls.crt:ro - - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.key:/dcntrlized/pki/tls.key:ro + # Browser-edge SPA overlay (contract §10.3): host `bun run build` output is + # served without a Go rebuild; falls back to the embedded placeholder when + # absent. DCNTRLIZED_WEB_DIR is honoured only in dev/debug mode. + - ./web/dist:/web-dist:ro working_dir: /app/ @@ -57,17 +55,26 @@ services: environment: # DCNTRLIZED_DEV is the single dev selector (registry/auth URLs). - DCNTRLIZED_DEV=true - # Security theme M2 §2: keep THIS listener plaintext so the RN client - # (direct mode) and hot reload work without certs. The auth proxy edge - # is still mTLS (mounted identity above). + # Contract rev 9: internal traffic is plaintext, so the debug selector no + # longer gates any listener — this listener is ALWAYS plaintext now. - DCNTRLIZED_DEBUG=true + # Browser edge (contract rev 9 §17.2): the SPA and /web/* ride the single + # :8080 port, served from the host-built overlay; dev is HTTP so + # COOKIE_SECURE=false. + - DCNTRLIZED_WEB_ENABLED=true + - DCNTRLIZED_WEB_DIR=/web-dist + - COOKIE_SECURE=false + # Hub base URL for the plaintext internal hop (rev 9: no mTLS identity). + # Overridable so a throwaway hub can be exercised without touching the + # shared dev stack. + - AUTH_SERVICE_URL=${AUTH_SERVICE_URL:-http://dcntrlized-auth:8080} # Application configuration # - PORT=8080 # - ENVIRONMENT=development # - LOG_LEVEL=debug # Database connection (shared database on the dcntrlized network) - - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized + - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable # Go specific settings - GO111MODULE=on @@ -102,7 +109,7 @@ services: - PGHOST=dcntrlized-db - PGPORT=5432 - PGUSER=postgres - - PGPASSWORD= + - PGPASSWORD=dcntrlized - PGDATABASE=dcntrlized command: > sh -c 'until pg_isready -h dcntrlized-db -U postgres -d dcntrlized; do sleep 2; done; diff --git a/docker-compose.yaml b/docker-compose.yaml index 0e105ae..f0790f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,9 +9,17 @@ services: ports: - "8081:8080" - "9090:90" + # Contract rev 9 §3.4: ONE plaintext listener, published as 8081:8080. The + # browser web app (SPA + /web/*) rides the same port — there is no separate + # browser port. Opt-in in prod via DCNTRLIZED_WEB_ENABLED=true; + # web.Validate() then fails closed unless DCNTRLIZED_WEB_ORIGINS is + # non-empty and COOKIE_SECURE=true. environment: - - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized + - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable - AUTH_SERVICE_URL=http://dcntrlized-auth:8080 + - DCNTRLIZED_WEB_ENABLED=${DCNTRLIZED_WEB_ENABLED:-false} + - DCNTRLIZED_WEB_ORIGINS=${DCNTRLIZED_WEB_ORIGINS:-} + - COOKIE_SECURE=${COOKIE_SECURE:-true} depends_on: - migrate build: @@ -31,7 +39,7 @@ services: - PGHOST=dcntrlized-db - PGPORT=5432 - PGUSER=postgres - - PGPASSWORD= + - PGPASSWORD=dcntrlized - PGDATABASE=dcntrlized command: > sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done; diff --git a/src/handlers/manifest/manifest.go b/src/handlers/manifest/manifest.go index 1588375..5b261c6 100644 --- a/src/handlers/manifest/manifest.go +++ b/src/handlers/manifest/manifest.go @@ -2,17 +2,62 @@ package manifest import ( _ "embed" + "encoding/json" "net/http" "git.dcntrlized.org/Dcntrlized/KeepNote/logger" ) //go:embed manifest.json -var manifest string +var rawManifest string -func ManifestHandler(w http.ResponseWriter, r *http.Request) { - logger.Debug("Sending manifest file") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(manifest)) +// base is the embedded manifest parsed once. The handler merges the dynamic +// `web` capability block onto it per request. +var base = mustParse(rawManifest) + +func mustParse(raw string) map[string]json.RawMessage { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &m); err != nil { + // The manifest is an embedded build asset: a malformed one is a + // build-time defect, not a runtime condition to paper over. + panic("manifest: embedded manifest.json is not a JSON object: " + err.Error()) + } + return m +} + +// WebBlock is the optional top-level Web App capability (contract rev 8.2/9). +// It is announced ONLY when the service actually serves the browser web app +// (honesty rule, fail-closed) and carries the shared single service port. +type WebBlock struct { + Enabled bool `json:"enabled"` + Port int `json:"port"` + URL string `json:"url"` +} + +// Handler serves /_manifest. +// +// The `web` block is computed per request from the live listener state: +// enabled reports whether the browser surface is mounted, and port is the +// service's single listener port (the SPA shares it with the API in rev 9, so +// there is no separate DCNTRLIZED_WEB_PORT). When enabled() is false the block +// is omitted entirely — a disabled listener is never announced. +func Handler(enabled func() bool, port int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Debug("Sending manifest file") + out := make(map[string]json.RawMessage, len(base)+1) + for k, v := range base { + out[k] = v + } + if enabled() { + block, err := json.Marshal(WebBlock{Enabled: true, Port: port}) + if err != nil { + http.Error(w, "manifest encoding failed", http.StatusInternalServerError) + return + } + out["web"] = block + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(out) + } } diff --git a/src/handlers/manifest/manifest_test.go b/src/handlers/manifest/manifest_test.go new file mode 100644 index 0000000..c187a05 --- /dev/null +++ b/src/handlers/manifest/manifest_test.go @@ -0,0 +1,70 @@ +package manifest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Contract rev 8.2/9: the `web` block is announced ONLY when the browser +// surface is actually mounted, and it carries the shared single service port. +func TestManifestWebBlockHonestyRule(t *testing.T) { + for _, tc := range []struct { + name string + enabled bool + want bool + }{ + {"enabled", true, true}, + {"disabled", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + Handler(func() bool { return tc.enabled }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", got) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("manifest is not a JSON object: %v (%s)", err, rec.Body.String()) + } + raw, ok := body["web"] + if ok != tc.want { + t.Fatalf("web block present = %v, want %v", ok, tc.want) + } + if !tc.want { + return + } + var block WebBlock + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("web block: %v", err) + } + if !block.Enabled { + t.Fatalf("web.enabled = false, want true") + } + if block.Port != 8080 { + t.Fatalf("web.port = %d, want 8080 (the single service port)", block.Port) + } + }) + } +} + +// The dynamic block is merged onto the embedded manifest without dropping any +// static capability. +func TestManifestKeepsStaticFields(t *testing.T) { + rec := httptest.NewRecorder() + Handler(func() bool { return true }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil)) + + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{"name", "has_auth", "capabilities"} { + if _, ok := body[key]; !ok { + t.Fatalf("static field %q missing from the served manifest", key) + } + } +} diff --git a/src/handlers/web/action.go b/src/handlers/web/action.go new file mode 100644 index 0000000..1ca6127 --- /dev/null +++ b/src/handlers/web/action.go @@ -0,0 +1,82 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "net/http" + "strings" +) + +// ActionFunc mirrors handlers/actions.ActionActionName. +type ActionFunc func(w http.ResponseWriter, r *http.Request, params map[string]string) + +// Dispatch mirrors the /action/ method routing. Wire it to the same +// functions main.go mounts on the API listener. +type Dispatch struct { + Get ActionFunc + Post ActionFunc + Put ActionFunc + Delete ActionFunc + // Allowed is the explicit per-service browser-action allowlist (contract + // §4.4). It is a name set, NOT a denylist: anything absent -> 404, so a + // newly added action stays closed on the browser edge until it is listed. + // The auth family is absent by construction. + Allowed map[string]bool +} + +// maxActionBody caps /web/action/* request bodies. 4 MiB is above the largest +// documented action payload (Calendar importIcs: 1 MiB decoded ~ 1.4 MiB base64). +const maxActionBody = 4 << 20 + +func ActionHandler(d Dispatch) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/web/action/") + if name == "" || strings.Contains(name, "/") { + writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + if !d.Allowed[name] { // explicit allowlist (RC2); no name, no route + writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + if _, ok := principal(w, r); !ok { + return + } + r2 := r.Clone(r.Context()) + r2.Header = r.Header.Clone() + r2.Header.Del("Cookie") + if c, err := r.Cookie(accessCookie); err == nil { + r2.Header.Set("X-Access-Token", c.Value) // overwrite any browser value + } + if r.Body != nil { // RC7: bounded memory; decode failures stay 400s + r2.Body = http.MaxBytesReader(w, r.Body, maxActionBody) + } + params := map[string]string{"actionName": name} + switch r.Method { + case http.MethodGet: + d.Get(w, r2, params) + case http.MethodPost: + d.Post(w, r2, params) + case http.MethodPut: + d.Put(w, r2, params) + case http.MethodDelete: + d.Delete(w, r2, params) + default: + w.Header().Set("Allow", "GET, POST, PUT, DELETE") + writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") + } + } +} diff --git a/src/handlers/web/auth.go b/src/handlers/web/auth.go new file mode 100644 index 0000000..1d08ce3 --- /dev/null +++ b/src/handlers/web/auth.go @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" +) + +const maxAuthBody = 8 << 10 // 8 KiB + +type hubUser struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` +} + +type hubAuthResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + User hubUser `json:"user"` +} + +func userResponse(u hubUser) map[string]any { return map[string]any{"user": u} } + +func LoginHandler() http.HandlerFunc { return authProxyHandler("login") } +func RegisterHandler() http.HandlerFunc { return authProxyHandler("register") } + +func authProxyHandler(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + raw, err := io.ReadAll(io.LimitReader(r.Body, maxAuthBody)) + if err != nil || !json.Valid(raw) { + writeCode(w, http.StatusBadRequest, "ERR_INVALID_REQUEST") + return + } + status, body, err := callHub(r.Context(), http.MethodPost, path, json.RawMessage(raw)) + if err != nil { + writeCode(w, http.StatusBadGateway, "ERR_INTERNAL") + return + } + if status != http.StatusOK { + relay(w, status, body) + return + } + var ar hubAuthResponse + if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" { + writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + setSessionCookies(w, ar.AccessToken, ar.RefreshToken) + writeJSON(w, http.StatusOK, userResponse(ar.User)) + } +} + +func RefreshHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + c, err := r.Cookie(refreshCookie) + if err != nil || c.Value == "" { + clearSessionCookies(w) + writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + status, body, err := callHub(r.Context(), http.MethodPost, "refresh", + map[string]string{"refreshToken": c.Value}) + if err != nil { + writeCode(w, http.StatusBadGateway, "ERR_INTERNAL") + return + } + if status != http.StatusOK { + clearSessionCookies(w) + relay(w, status, body) + return + } + var ar hubAuthResponse + if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" { + clearSessionCookies(w) + writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + setSessionCookies(w, ar.AccessToken, ar.RefreshToken) + writeJSON(w, http.StatusOK, userResponse(ar.User)) + } +} + +func LogoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" { + _, _, _ = callHub(r.Context(), http.MethodPost, "logout", + map[string]string{"refreshToken": c.Value}) + } + clearSessionCookies(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"}) + } +} + +func MeHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + p, ok := principal(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, userResponse(hubUser{ + ID: p.ID.String(), Username: p.Username, Email: p.Email, + })) + } +} + +// principal validates the access cookie with the shared authclient path +// (RS256 + iss + exp + type=access). It writes the uniform 401 itself. +func principal(w http.ResponseWriter, r *http.Request) (*authclient.Principal, bool) { + c, err := r.Cookie(accessCookie) + if err != nil || c.Value == "" { + writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return nil, false + } + // Cheap syntactic gate first: a malformed value must never reach + // authclient's lazy hub key-fetch path (which can cost a 10s outbound + // attempt when the hub is down). This is NOT verification. + if strings.Count(c.Value, ".") != 2 { + writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return nil, false + } + clone := r.Clone(r.Context()) + clone.Header = r.Header.Clone() + clone.Header.Set("X-Access-Token", c.Value) + p, err := authclient.Authorize(clone) + if err != nil { + writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return nil, false + } + return p, true +} + +func relay(w http.ResponseWriter, status int, body []byte) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(body) +} diff --git a/src/handlers/web/config.go b/src/handlers/web/config.go new file mode 100644 index 0000000..6efef8b --- /dev/null +++ b/src/handlers/web/config.go @@ -0,0 +1,120 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "errors" + "net/url" + "os" + "strings" +) + +const ( + EnvEnabled = "DCNTRLIZED_WEB_ENABLED" + EnvOrigins = "DCNTRLIZED_WEB_ORIGINS" + EnvDir = "DCNTRLIZED_WEB_DIR" + EnvCookieSecure = "COOKIE_SECURE" + EnvAccessMaxAge = "DCNTRLIZED_WEB_ACCESS_MAX_AGE" + EnvRefreshMaxAge = "DCNTRLIZED_WEB_REFRESH_MAX_AGE" + + DefaultAccessMaxAge = 900 + DefaultRefreshMaxAge = 604800 +) + +// Enabled reports whether the browser surface must be mounted on the service's +// single :8080 listener. Unset/unknown values default to on in dev/debug and +// off in prod (opt-in). +// +// Contract rev 9: there is no separate browser port any more +// (DCNTRLIZED_WEB_PORT is gone) — the SPA and /web/* ride the same listener as +// the API, so this is purely a mount switch. +func Enabled() bool { + switch os.Getenv(EnvEnabled) { + case "true": + return true + case "false": + return false + default: + return devMode() + } +} + +// devMode reports the dev/debug selector directly from the environment. It no +// longer depends on the shared tlsutil package: rev 9 removed the transport +// security layer (and with it the reason to consult it here). +func devMode() bool { + return os.Getenv("DCNTRLIZED_DEBUG") == "true" || os.Getenv("DCNTRLIZED_DEV") == "true" +} + +// ErrDisabled is returned by Mount when the browser surface is disabled. The +// disabled state is enforced in code, not by caller convention, so a disabled +// edge can never mount a route even if a caller forgets the `if web.Enabled()` +// guard (S31 rewrite: there is no second socket to bind any more). +var ErrDisabled = errors.New("web: browser edge disabled") + +// Validate fails closed when the browser edge is enabled outside dev/debug: an +// Origin allowlist is mandatory (DNS-rebinding hardening) and session cookies +// must be Secure (F3 — never rely on the compose default alone). The old "https +// hub base" requirement is gone with the de-mTLS migration. +func Validate() error { + if !Enabled() || devMode() { + return nil + } + if len(origins()) == 0 { + return errors.New("web: DCNTRLIZED_WEB_ORIGINS is required when the browser edge is enabled outside dev/debug") + } + if !cookieSecure() { + return errors.New("web: COOKIE_SECURE=true is required when the browser edge is enabled outside dev/debug") + } + return nil +} + +func origins() []string { + raw := os.Getenv(EnvOrigins) + if strings.TrimSpace(raw) == "" { + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + if o := normalizeOrigin(part); o != "" { + out = append(out, o) + } + } + return out +} + +// normalizeOrigin returns the canonical scheme://host form (lowercase, default +// port stripped) or "" when the entry is unusable. +func normalizeOrigin(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil { + return "" + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Host) + if (scheme == "https" && strings.HasSuffix(host, ":443")) || + (scheme == "http" && strings.HasSuffix(host, ":80")) { + host = host[:strings.LastIndex(host, ":")] + } + return scheme + "://" + host +} diff --git a/src/handlers/web/cookies.go b/src/handlers/web/cookies.go new file mode 100644 index 0000000..0beae89 --- /dev/null +++ b/src/handlers/web/cookies.go @@ -0,0 +1,62 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "net/http" + "os" + "strconv" +) + +const ( + accessCookie = "dcntrlized_access" + refreshCookie = "dcntrlized_refresh" + cookiePath = "/web" +) + +func cookieSecure() bool { return os.Getenv(EnvCookieSecure) == "true" } + +func maxAge(env string, def int) int { + if v := os.Getenv(env); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + } + return def +} + +func sessionCookie(name, value string, age int) *http.Cookie { + return &http.Cookie{ + Name: name, Value: value, Path: cookiePath, + HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: cookieSecure(), MaxAge: age, + } +} + +func setSessionCookies(w http.ResponseWriter, access, refresh string) { + http.SetCookie(w, sessionCookie(accessCookie, access, maxAge(EnvAccessMaxAge, DefaultAccessMaxAge))) + http.SetCookie(w, sessionCookie(refreshCookie, refresh, maxAge(EnvRefreshMaxAge, DefaultRefreshMaxAge))) +} + +func clearSessionCookies(w http.ResponseWriter) { + for _, name := range []string{accessCookie, refreshCookie} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: cookiePath, + HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: cookieSecure(), MaxAge: -1, + }) + } +} diff --git a/src/handlers/web/hub.go b/src/handlers/web/hub.go new file mode 100644 index 0000000..a0a7a94 --- /dev/null +++ b/src/handlers/web/hub.go @@ -0,0 +1,66 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +const maxHubBody = 1 << 20 // 1 MiB + +// hubClient is the outbound hub transport. Contract rev 9 removed the mTLS +// identity layer fleet-wide, so this is a plain HTTP client — no client +// certificate and no custom TLS configuration. The timeout bounds the whole +// hub exchange so a stalled hub cannot pin a browser request. +var hubClient = &http.Client{Timeout: 10 * time.Second} + +// callHub performs one fixed hub /auth/* call over the plaintext internal +// network. It deliberately does NOT use authproxy.Proxy: that helper copies +// every inbound header (including Cookie and X-Access-Token) to the hub, which +// is forbidden on the browser edge. +func callHub(ctx context.Context, method, path string, payload any) (int, []byte, error) { + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, authproxy.Target()+"/auth/"+path, body) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := hubClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + out, err := io.ReadAll(io.LimitReader(resp.Body, maxHubBody)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, out, nil +} diff --git a/src/handlers/web/json.go b/src/handlers/web/json.go new file mode 100644 index 0000000..af101b0 --- /dev/null +++ b/src/handlers/web/json.go @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "encoding/json" + "net/http" +) + +type errorBody struct { + Error struct { + Code string `json:"code"` + } `json:"error"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeCode(w http.ResponseWriter, status int, code string) { + var b errorBody + b.Error.Code = code + writeJSON(w, status, b) +} + +func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool { + if r.Method != method { + w.Header().Set("Allow", method) + writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") + return false + } + return true +} diff --git a/src/handlers/web/listener.go b/src/handlers/web/listener.go new file mode 100644 index 0000000..a7e071a --- /dev/null +++ b/src/handlers/web/listener.go @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "io/fs" + "log" + "net/http" +) + +// Config wires the browser surface. +type Config struct { + Embedded fs.FS + Dispatch Dispatch + Logger Logger +} + +// NewMux builds the exact browser-edge surface. Nothing else is mounted. +func NewMux(cfg Config) (*http.ServeMux, error) { + spa, err := NewSPA(cfg.Embedded) + if err != nil { + return nil, err + } + mux := http.NewServeMux() + mux.HandleFunc("/web/auth/login", LoginHandler()) + mux.HandleFunc("/web/auth/register", RegisterHandler()) + mux.HandleFunc("/web/auth/refresh", RefreshHandler()) + mux.HandleFunc("/web/auth/logout", LogoutHandler()) + mux.HandleFunc("/web/auth/me", MeHandler()) + mux.HandleFunc("/web/action/", ActionHandler(cfg.Dispatch)) + mux.Handle("/", spa) + return mux, nil +} + +// Mount registers the browser surface on the service's single listener mux. +// +// Contract rev 9: there is no dedicated browser port and no second socket. The +// SPA and /web/* ride the same :8080 listener as the API, so this only wires +// routes — the caller owns the http.Server and its RC17 timeouts. +// +// The registered chain is AccessLog(APIHeaders(CSRF(mux))). It deliberately +// does NOT include LoggingMiddleware (which logs body=%q, including login +// bodies) or CorsMiddleware (which echoes any Origin and allows +// X-Access-Token) — S48. +// +// Only "/" is registered: the browser mux's own exact /web/auth/* routes win +// over its SPA fallback, and the API mux's "/auth/", "/action/" and +// "/_manifest" patterns are more specific than "/" so they keep routing to the +// API chain. That also preserves the reserved-prefix behaviour for the bare +// "/web" path (JSON 404 from the SPA, not a redirect). +// +// Mount returns ErrDisabled when the edge is off, so a caller can never mount a +// dead app by accident (S31). +func Mount(outer *http.ServeMux, cfg Config) error { + if !Enabled() { + return ErrDisabled + } + if err := Validate(); err != nil { + return err + } + mux, err := NewMux(cfg) + if err != nil { + return err + } + logger := cfg.Logger + if logger == nil { + logger = log.Default() + } + outer.Handle("/", AccessLog(logger, APIHeaders(CSRF(mux)))) + return nil +} diff --git a/src/handlers/web/middleware.go b/src/handlers/web/middleware.go new file mode 100644 index 0000000..c96dfd1 --- /dev/null +++ b/src/handlers/web/middleware.go @@ -0,0 +1,121 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func mutating(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + } + return false +} + +func originAllowed(r *http.Request) bool { + raw := r.Header.Get("Origin") + if raw == "" { + raw = r.Header.Get("Referer") + } + if raw == "" { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil { // F5: no userinfo form + return false + } + allow := origins() + if len(allow) == 0 { + return strings.EqualFold(u.Host, r.Host) + } + got := normalizeOrigin(u.Scheme + "://" + u.Host) + for _, a := range allow { + if got == a { + return true + } + } + return false +} + +// CSRF mirrors Maestro's ERR_CSRF contract (403 + {"error":{"code"}}) but is +// stricter: the browser edge is browser-only, so a missing Origin/Referer is +// refused rather than allowed. +func CSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if mutating(r.Method) && !originAllowed(r) { + writeCode(w, http.StatusForbidden, "ERR_CSRF") + return + } + next.ServeHTTP(w, r) + }) +} + +// APIHeaders hardens every browser-API response (RC1). It is mounted around +// the whole browser mux, so it also covers 401/403/404/405/413 error paths. +// SPA responses are deliberately excluded (they keep their own headers and +// must stay cacheable). +func APIHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/web/") { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + } + next.ServeHTTP(w, r) + }) +} + +// Logger is satisfied by *log.Logger and by tiny service adapters. +type Logger interface{ Printf(format string, v ...any) } + +// safeLogPath neutralises log-injection characters (F2). EscapedPath keeps the +// original percent-encoding, so an encoded %0a/%0d can never become a real +// newline; Quote makes any residual control character explicit and visible. +func safeLogPath(r *http.Request) string { + p := r.URL.EscapedPath() + if p == "" { + p = "/" + } + if len(p) > 256 { + p = p[:256] + "..." + } + return strconv.Quote(p) +} + +// AccessLog never logs headers, bodies or query strings. +func AccessLog(logger Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + logger.Printf("web %s %s status=%d duration=%s", r.Method, safeLogPath(r), rec.status, time.Since(start)) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} diff --git a/src/handlers/web/spa.go b/src/handlers/web/spa.go new file mode 100644 index 0000000..cbfa69e --- /dev/null +++ b/src/handlers/web/spa.go @@ -0,0 +1,144 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "io/fs" + "net/http" + "net/url" + "os" + "path" + "strings" +) + +var reserved = []string{"/web/", "/auth/", "/action/", "/config", "/_manifest", "/api/"} + +// isReserved reports whether p is, or is below, a reserved prefix. Matching is +// exact or path-boundary only (RC18): /configx, /configuration and /_manifestx +// are NOT reserved (they may be real SPA routes), while /config, /config/..., +// /_manifest, /_manifest/..., /web, /web/..., /auth/..., /action/... and +// /api/... are. +func isReserved(p string) bool { + for _, pre := range reserved { + base := strings.TrimSuffix(pre, "/") + if p == base || strings.HasPrefix(p, base+"/") { + return true + } + } + return false +} + +// reservedPath decides whether the request must be answered with the reserved +// JSON 404 instead of the SPA shell. Go decodes the request path once, so a +// double-encoded traversal such as /%252e%252e/config arrives as +// /%2e%2e/config and only becomes /config after a second decode; checking up to +// two extra decodes keeps that (and any undecodable encoding) fail-closed. +func reservedPath(r *http.Request) bool { + if isReserved(path.Clean("/" + r.URL.Path)) { + return true + } + esc := r.URL.EscapedPath() + if esc == "" { + return false + } + for i := 0; i < 2; i++ { + dec, err := url.PathUnescape(esc) + if err != nil { + return true // undecodable encoding: never serve the shell + } + if isReserved(path.Clean("/" + dec)) { + return true + } + esc = dec + } + return false +} + +// SPA serves the embedded bundle at web/dist, or the live directory named by +// DCNTRLIZED_WEB_DIR when it exists. The disk overlay is a DEV-ONLY hot-reload +// affordance (F4): outside devMode() it is ignored entirely, so the variable can +// never make the browser listener serve arbitrary container files. +type SPA struct{ fs http.FileSystem } + +func NewSPA(embedded fs.FS) (*SPA, error) { + if devMode() { + if dir := strings.TrimSpace(os.Getenv(EnvDir)); dir != "" { + if st, err := os.Stat(dir); err == nil && st.IsDir() { + return &SPA{fs: http.Dir(dir)}, nil + } + } + } + sub, err := fs.Sub(embedded, "web/dist") + if err != nil { + return nil, err + } + return &SPA{fs: http.FS(sub)}, nil +} + +func securityHeaders(w http.ResponseWriter) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "+ + "base-uri 'none'; form-action 'self'") +} + +func (s *SPA) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") + return + } + if reservedPath(r) { + writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + upath := path.Clean("/" + r.URL.Path) + securityHeaders(w) + if f, err := s.fs.Open(upath); err == nil { + st, serr := f.Stat() + if serr == nil && !st.IsDir() { + if strings.HasPrefix(upath, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + http.ServeContent(w, r, path.Base(upath), st.ModTime(), f) + _ = f.Close() + return + } + _ = f.Close() + } + s.serveIndex(w, r) +} + +func (s *SPA) serveIndex(w http.ResponseWriter, r *http.Request) { + f, err := s.fs.Open("/index.html") + if err != nil { + writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.IsDir() { + writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, "index.html", st.ModTime(), f) +} diff --git a/src/handlers/web/web_test.go b/src/handlers/web/web_test.go new file mode 100644 index 0000000..d853bb3 --- /dev/null +++ b/src/handlers/web/web_test.go @@ -0,0 +1,254 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package web + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +// prodEnv clears the dev selectors so the fail-closed prod branches run. +func prodEnv(t *testing.T) { + t.Helper() + t.Setenv(EnvEnabled, "true") + t.Setenv("DCNTRLIZED_DEBUG", "") + t.Setenv("DCNTRLIZED_DEV", "") +} + +// S31 (RC10/F1): Mount refuses to register the browser surface when the edge is +// disabled, even if the caller forgot the `if web.Enabled()` guard. Rev 9 has no +// second socket to bind — the guard is now a mount guard. +func TestMountRefusesWhenDisabled(t *testing.T) { + t.Setenv(EnvEnabled, "false") + outer := http.NewServeMux() + err := Mount(outer, Config{Embedded: fstest.MapFS{}}) + if !errors.Is(err, ErrDisabled) { + t.Fatalf("Mount error = %v, want ErrDisabled", err) + } + rec := httptest.NewRecorder() + outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/notes", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("disabled edge served %d on /notes, want 404 (nothing mounted)", rec.Code) + } +} + +// S23/RC6 + S33 (RC12/F3): outside dev/debug the edge requires an Origin +// allowlist and COOKIE_SECURE=true. The old https-hub requirement is gone with +// the fleet-wide de-mTLS migration (rev 9). +func TestValidateProdFailClosed(t *testing.T) { + prodEnv(t) + t.Setenv(EnvOrigins, "") + t.Setenv(EnvCookieSecure, "") + if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvOrigins) { + t.Fatalf("Validate without origins = %v, want origins error", err) + } + + t.Setenv(EnvOrigins, "https://notes.example.org") + t.Setenv(EnvCookieSecure, "false") + if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvCookieSecure) { + t.Fatalf("Validate without COOKIE_SECURE = %v, want cookie error", err) + } + + t.Setenv(EnvCookieSecure, "true") + if err := Validate(); err != nil { + t.Fatalf("Validate with full prod config = %v, want nil", err) + } +} + +// S34 (RC13/F4): DCNTRLIZED_WEB_DIR is a dev-only overlay; in prod the +// embedded FS is always used. +func TestSPADirIsDevOnly(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("OVERLAY"), 0o600); err != nil { + t.Fatal(err) + } + embedded := fstest.MapFS{"web/dist/index.html": {Data: []byte("EMBEDDED")}} + + prodEnv(t) + t.Setenv(EnvDir, dir) + spa, err := NewSPA(embedded) + if err != nil { + t.Fatal(err) + } + if body := getBody(t, spa, "/"); !strings.Contains(body, "EMBEDDED") || strings.Contains(body, "OVERLAY") { + t.Fatalf("prod SPA body = %q, want embedded content only", body) + } + + t.Setenv("DCNTRLIZED_DEBUG", "true") + spa, err = NewSPA(embedded) + if err != nil { + t.Fatal(err) + } + if body := getBody(t, spa, "/"); !strings.Contains(body, "OVERLAY") { + t.Fatalf("debug SPA body = %q, want disk overlay", body) + } +} + +// S28 (RC2): the action allowlist is fail-closed and is checked before the +// session, so unknown and auth-family names are 404 even without a cookie. +func TestActionAllowlistIsFailClosed(t *testing.T) { + reached := false + handler := ActionHandler(Dispatch{ + Get: func(http.ResponseWriter, *http.Request, map[string]string) { reached = true }, + Allowed: map[string]bool{"getNotes": true}, + }) + for _, name := range []string{"login", "register", "refresh", "logout", "delete", "me", "nope"} { + reached = false + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, "/web/action/"+name, nil)) + if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") { + t.Fatalf("action %q = %d %s, want 404 ERR_NOT_FOUND", name, rec.Code, rec.Body.String()) + } + if reached { + t.Fatalf("action %q reached the dispatch despite the allowlist", name) + } + } +} + +// S29 (RC1): every /web/* response carries no-store + nosniff, including +// error paths produced before the handler runs. +func TestAPIHeadersOnErrorPaths(t *testing.T) { + mux, err := NewMux(Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}}) + if err != nil { + t.Fatal(err) + } + h := APIHeaders(CSRF(mux)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}")) + req.Header.Set("Origin", "https://evil.example") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("foreign-origin login = %d, want 403", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } +} + +// S48 (rev 9): the browser chain never inherits CorsMiddleware. A mutating +// browser request must not receive any Access-Control-* header, whatever the +// Origin, and a foreign Origin must still be refused by CSRF. +func TestBrowserChainHasNoCORS(t *testing.T) { + t.Setenv(EnvEnabled, "true") + t.Setenv("DCNTRLIZED_DEBUG", "true") + outer := http.NewServeMux() + if err := Mount(outer, Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}}); err != nil { + t.Fatal(err) + } + for _, origin := range []string{"https://evil.example", "http://localhost:8081"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}")) + req.Header.Set("Origin", origin) + outer.ServeHTTP(rec, req) + for name := range rec.Header() { + if strings.HasPrefix(http.CanonicalHeaderKey(name), "Access-Control-") { + t.Fatalf("browser chain emitted %s for Origin %q (CorsMiddleware leaked)", name, origin) + } + } + } + // The SPA shell itself carries no CORS header either. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/notes", nil) + req.Header.Set("Origin", "https://evil.example") + outer.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("SPA deep link = %d, want 200", rec.Code) + } + if h := rec.Header().Get("Access-Control-Allow-Origin"); h != "" { + t.Fatalf("SPA response echoed Origin %q", h) + } +} + +// S32 (RC11/F2): log paths are quoted and control characters never survive. +func TestSafeLogPathNoInjection(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/notes%0aforge", nil) + got := safeLogPath(req) + if strings.ContainsAny(got, "\n\r") { + t.Fatalf("safeLogPath = %q contains a raw control character", got) + } + if !strings.Contains(got, `%0a`) && !strings.Contains(got, `\n`) { + t.Fatalf("safeLogPath = %q, want the encoded newline preserved/quoted", got) + } + if long := safeLogPath(httptest.NewRequest(http.MethodGet, "/"+strings.Repeat("a", 400), nil)); len(long) > 300 { + t.Fatalf("safeLogPath did not cap length: %d", len(long)) + } +} + +// RC18: reserved matching is exact or path-boundary, and a double-encoded +// traversal still lands on the reserved JSON 404. +func TestReservedBoundaryAndDoubleEncoding(t *testing.T) { + reserved := []string{"/web", "/web/x", "/config", "/config/x", "/_manifest", "/api/v1"} + for _, p := range reserved { + if !isReserved(p) { + t.Fatalf("isReserved(%q) = false, want true", p) + } + } + allowed := []string{"/configx", "/configuration", "/_manifestx", "/webx", "/apiary", "/notes"} + for _, p := range allowed { + if isReserved(p) { + t.Fatalf("isReserved(%q) = true, want false (boundary matching)", p) + } + } + for _, raw := range []string{"/%252e%252e/config", "/%2e%2e/config"} { + req := httptest.NewRequest(http.MethodGet, raw, nil) + if !reservedPath(req) { + t.Fatalf("reservedPath(%q) = false, want true", raw) + } + } +} + +// SPA fallback: an existing asset is served, an unknown route falls back to +// index.html, and a reserved prefix never returns HTML. +func TestSPAFallbackAndReserved(t *testing.T) { + embedded := fstest.MapFS{ + "web/dist/index.html": {Data: []byte("SHELL")}, + "web/dist/assets/app.js": {Data: []byte("JS")}, + } + spa, err := NewSPA(embedded) + if err != nil { + t.Fatal(err) + } + if body := getBody(t, spa, "/notes/abc"); body != "SHELL" { + t.Fatalf("fallback body = %q, want SHELL", body) + } + if body := getBody(t, spa, "/assets/app.js"); body != "JS" { + t.Fatalf("asset body = %q, want JS", body) + } + rec := httptest.NewRecorder() + spa.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil)) + if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") { + t.Fatalf("/config on SPA = %d %s, want 404 JSON", rec.Code, rec.Body.String()) + } +} + +func getBody(t *testing.T, h http.Handler, target string) string { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", target, rec.Code) + } + return rec.Body.String() +} diff --git a/src/main.go b/src/main.go index 8a9cfb1..0dda1d6 100644 --- a/src/main.go +++ b/src/main.go @@ -2,25 +2,36 @@ package main import ( "context" - "crypto/tls" - "net" + "log" "net/http" "os" "strings" + "time" "git.dcntrlized.org/Dcntrlized/KeepNote/db" "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/actions" "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/auth" "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/manifest" + "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/web" "git.dcntrlized.org/Dcntrlized/KeepNote/logger" "git.dcntrlized.org/Dcntrlized/KeepNote/middleware" "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" - "git.dcntrlized.org/dcntrlized/dcntrlized-auth/tlsutil" ) -func main() { +// servicePort is the service's ONLY listening port (contract rev 9 §3.3). The +// API and the browser web app (SPA + /web/*) share it; there is no second +// listener, no DCNTRLIZED_WEB_PORT and no peer edge. +const servicePort = "8080" +// browserActions is the explicit browser-edge allowlist (contract §4.4). +// KeepNote; Calendar uses its own §4.4 list. Unknown names -> 404. +var browserActions = map[string]bool{ + "getNotes": true, "getNote": true, + "writeNote": true, "updateNote": true, "deleteNote": true, +} + +func main() { db := db.NewDB() defer db.Close() @@ -29,24 +40,30 @@ func main() { } logger.Info("Server started") - mux := http.NewServeMux() - mux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) { + // The API surface. It is reached through the API chain + // (LoggingMiddleware(CorsMiddleware(apiMux))), never through the browser + // chain — the browser chain must not log bodies or echo Origins (S48). + apiMux := http.NewServeMux() + + apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - manifest.ManifestHandler(w, r) + // Honesty rule (rev 8.2/9): the `web` block is announced only while the + // browser surface is actually mounted, on the shared single port. + manifest.Handler(web.Enabled, 8080)(w, r) }) - mux.HandleFunc("/auth/login", auth.LoginHandler()) - mux.HandleFunc("/auth/register", auth.RegisterHandler()) - mux.HandleFunc("/auth/logout", auth.LogoutHandler()) - mux.HandleFunc("/auth/refresh", auth.RefreshHandler()) - mux.HandleFunc("/auth/me", auth.MeHandler()) - mux.HandleFunc("/auth/delete", auth.DeleteHandler()) + apiMux.HandleFunc("/auth/login", auth.LoginHandler()) + apiMux.HandleFunc("/auth/register", auth.RegisterHandler()) + apiMux.HandleFunc("/auth/logout", auth.LogoutHandler()) + apiMux.HandleFunc("/auth/refresh", auth.RefreshHandler()) + apiMux.HandleFunc("/auth/me", auth.MeHandler()) + apiMux.HandleFunc("/auth/delete", auth.DeleteHandler()) - mux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) { + apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/action/") { http.NotFound(w, r) return @@ -72,38 +89,56 @@ func main() { } }) - handler := middleware.LoggingMiddleware(middleware.CorsMiddleware(mux)) + // The single listener mux. Pattern precedence (Go 1.22 ServeMux) keeps the + // API prefixes off the browser chain: + // + // /_manifest, /auth/, /action/ -> apiChain + // / -> browser chain (SPA + /web/*) + outer := http.NewServeMux() - // Security theme M2 §2: the service's listener requires a CA-issued client - // cert (Maestro's identity) so only Maestro can drive actions (T4) and the - // peer edge is encrypted. DCNTRLIZED_DEBUG=true keeps the debug fleet plaintext. - tlsCfg, err := tlsutil.ServerConfig() - if err != nil { - logger.Error("TLS setup failed: %v", err) - os.Exit(1) - } - if tlsCfg == nil { - logger.Info("KeepNote listening plaintext on :8080 (DCNTRLIZED_DEBUG dev mode)") - if err := http.ListenAndServe(":8080", handler); err != nil { - logger.Error("HTTP server failed: %v", err) + apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux)) + outer.Handle("/_manifest", apiChain) + outer.Handle("/auth/", apiChain) + outer.Handle("/action/", apiChain) + + // Browser edge (contract rev 9 §17.2): the SPA and /web/* on the SAME + // :8080. DCNTRLIZED_WEB_ENABLED is the mount switch; disabled means no + // browser routes at all (there is no socket to bind any more). + if web.Enabled() { + if err := web.Mount(outer, web.Config{ + Embedded: embeddedWeb, + Dispatch: web.Dispatch{ + Get: actions.ActionActionNameGet, + Post: actions.ActionActionNamePost, + Put: actions.ActionActionNamePut, + Delete: actions.ActionActionNameDelete, + Allowed: browserActions, + }, + Logger: log.New(os.Stdout, "web ", log.LstdFlags|log.LUTC), + }); err != nil { + logger.Error("browser edge mount failed: %v", err) os.Exit(1) } - return + logger.Info("browser edge mounted on :%s (SPA + /web/*)", servicePort) + } else { + logger.Info("browser edge disabled (DCNTRLIZED_WEB_ENABLED); SPA routes not mounted") } - logger.Info("KeepNote listening on :8080 (mTLS, ClientAuth=RequireAndVerify)") - if err := serveTLS(":8080", tlsCfg, handler); err != nil { - logger.Error("HTTPS server failed: %v", err) + // Contract rev 9 §3.3: ONE plaintext listener per service. No TLS server + // configuration, no client certificates, no second socket. RC17 timeouts + // cover the API and the SPA alike. + server := &http.Server{ + Addr: ":" + servicePort, + Handler: outer, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + logger.Info("KeepNote listening plaintext on :%s (single port: API + SPA)", servicePort) + if err := server.ListenAndServe(); err != nil { + logger.Error("HTTP server failed: %v", err) os.Exit(1) } } - -// serveTLS runs an HTTPS listener with the given mTLS config. -func serveTLS(addr string, cfg *tls.Config, handler http.Handler) error { - ln, err := net.Listen("tcp", addr) - if err != nil { - return err - } - tlsLn := tls.NewListener(ln, cfg) - return http.Serve(tlsLn, handler) -} diff --git a/src/web/dist/index.html b/src/web/dist/index.html new file mode 100644 index 0000000..ae4711f --- /dev/null +++ b/src/web/dist/index.html @@ -0,0 +1,19 @@ + + + + + + KeepNote + + + +
+

KeepNote web bundle not built. Run cd web && bun run build.

+ + diff --git a/src/web_assets.go b/src/web_assets.go new file mode 100644 index 0000000..8fc01d8 --- /dev/null +++ b/src/web_assets.go @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import "embed" + +//go:embed all:web/dist +var embeddedWeb embed.FS diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..746e20d --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,11 @@ +# bun install/temp dirs (sandbox: default bun dirs are not writable) +.bun/ +.buntmp/ + +# build output + deps +dist/ +node_modules/ + +# editor/OS +.DS_Store +*.log diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..02e29dc --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,377 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "keepnote-web", + "dependencies": { + "@preact/signals": "^2.0.1", + "preact": "^10.25.4", + }, + "devDependencies": { + "@preact/preset-vite": "^2.9.4", + "@tailwindcss/vite": "^4.0.6", + "tailwindcss": "^4.0.6", + "typescript": "^5.7.3", + "vite": "^6.0.11", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@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/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@preact/preset-vite": ["@preact/preset-vite@2.10.6", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="], + + "@preact/signals": ["@preact/signals@2.11.3", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw=="], + + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], + + "@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.4", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="], + + "@prefresh/core": ["@prefresh/core@1.5.11", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="], + + "@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="], + + "@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.5", "", { "os": "android", "cpu": "arm" }, "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.5", "", { "os": "android", "cpu": "arm64" }, "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.5", "", { "os": "linux", "cpu": "none" }, "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.5", "", { "os": "linux", "cpu": "x64" }, "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.5", "", { "os": "none", "cpu": "arm64" }, "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="], + + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "esbuild": ["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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + + "node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "rollup": ["rollup@4.64.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.5", "@rollup/rollup-android-arm64": "4.64.5", "@rollup/rollup-darwin-arm64": "4.64.5", "@rollup/rollup-darwin-x64": "4.64.5", "@rollup/rollup-freebsd-arm64": "4.64.5", "@rollup/rollup-freebsd-x64": "4.64.5", "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", "@rollup/rollup-linux-arm-musleabihf": "4.64.5", "@rollup/rollup-linux-arm64-gnu": "4.64.5", "@rollup/rollup-linux-arm64-musl": "4.64.5", "@rollup/rollup-linux-loong64-gnu": "4.64.5", "@rollup/rollup-linux-loong64-musl": "4.64.5", "@rollup/rollup-linux-ppc64-gnu": "4.64.5", "@rollup/rollup-linux-ppc64-musl": "4.64.5", "@rollup/rollup-linux-riscv64-gnu": "4.64.5", "@rollup/rollup-linux-riscv64-musl": "4.64.5", "@rollup/rollup-linux-s390x-gnu": "4.64.5", "@rollup/rollup-linux-x64-gnu": "4.64.5", "@rollup/rollup-linux-x64-musl": "4.64.5", "@rollup/rollup-openbsd-x64": "4.64.5", "@rollup/rollup-openharmony-arm64": "4.64.5", "@rollup/rollup-win32-arm64-msvc": "4.64.5", "@rollup/rollup-win32-ia32-msvc": "4.64.5", "@rollup/rollup-win32-x64-gnu": "4.64.5", "@rollup/rollup-win32-x64-msvc": "4.64.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "stack-trace": ["stack-trace@1.0.0", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + + "vite": ["vite@6.4.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q=="], + + "vite-prerender-plugin": ["vite-prerender-plugin@0.5.14", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], + + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9395005 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + KeepNote + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..827a705 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "keepnote-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@preact/signals": "^2.0.1", + "preact": "^10.25.4" + }, + "devDependencies": { + "@preact/preset-vite": "^2.9.4", + "@tailwindcss/vite": "^4.0.6", + "tailwindcss": "^4.0.6", + "typescript": "^5.7.3", + "vite": "^6.0.11" + } +} diff --git a/web/src/components/Banner.tsx b/web/src/components/Banner.tsx new file mode 100644 index 0000000..f5d135c --- /dev/null +++ b/web/src/components/Banner.tsx @@ -0,0 +1,15 @@ +import type { ComponentChildren } from 'preact'; + +interface BannerProps { + kind: 'error' | 'warn' | 'info'; + children: ComponentChildren; + role?: 'alert' | 'status'; +} + +export function Banner({ kind, children, role = 'alert' }: BannerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..e24cf6a --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; + +interface ConfirmDialogProps { + title: string; + body: ComponentChildren; + confirmLabel: string; + cancelLabel?: string; + busy: boolean; + busyLabel?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + title, + body, + confirmLabel, + cancelLabel = 'Cancel', + busy, + busyLabel, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const titleId = useRef(`dlg-${Math.random().toString(36).slice(2, 8)}`).current; + const bodyId = `body-${titleId}`; + const cancelRef = useRef(null); + const confirmRef = useRef(null); + + useEffect(() => { + // focus the Cancel (default) button on open + cancelRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // simple focus trap + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const focusables = [cancelRef.current, confirmRef.current].filter(Boolean) as HTMLElement[]; + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + + return ( +
+
+

+ {title} +

+

+ {body} +

+
+ + +
+
+
+ ); +} diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx new file mode 100644 index 0000000..6805e50 --- /dev/null +++ b/web/src/components/Shell.tsx @@ -0,0 +1,180 @@ +import type { ComponentChildren } from 'preact'; +import { Link } from '../router'; +import { authState, boot, bootError, cachedProfileUsername } from '../lib/auth'; +import { banner, clearBanner, profile } from '../lib/api'; +import { pendingCount } from '../state/store'; +import type { RouteName } from '../router'; + +function Wordmark({ compact = false }: { compact?: boolean }) { + return ( + + KeepNote + + ); +} + +function SettingsIcon() { + return ( + + ); +} + +function ChevronLeft() { + return ( + + ); +} + +interface ShellProps { + route: RouteName; + title?: string; + backTo?: string; + children: ComponentChildren; +} + +export function Shell({ route, title, backTo, children }: ShellProps) { + const unknown = authState.value === 'unknown'; + const tBanner = banner.value; + const pending = pendingCount(); + const username = profile.value?.username ?? cachedProfileUsername(); + + const retryBoot = () => { + clearBanner(); + boot(); + }; + + const railActive = (r: RouteName) => (route === r ? 'true' : undefined); + + return ( +
+ + Skip to content + + + {unknown && ( +
+ Can't reach the server. + +
+ )} + {tBanner && ( +
+ {tBanner.text} + +
+ )} + +
+ {/* Persistent left rail (>=1024) */} + + +
+ {/* Top bar (<1024) */} +
+ {route === '/notes' && ( + <> + + + + + + + + )} + {route === '/settings' && ( + <> + + + + + {title ?? 'Settings'} + + + )} + {(route === '/notes/new' || route === '/notes/:noteId') && ( + <> + + + + + {title ?? ''} + + + )} +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx new file mode 100644 index 0000000..4752473 --- /dev/null +++ b/web/src/components/Spinner.tsx @@ -0,0 +1,12 @@ +export function Spinner() { + return