From 0d76c81f009730dbec2728621eb8f07e9cfa4c69 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 16 Aug 2026 15:41:39 +0300 Subject: [PATCH] feat: deploy Domovoy Creator web console --- cmd/creator-web/main.go | 33 +++ cmd/creator-web/server.go | 156 +++++++++++++ cmd/creator-web/server_test.go | 70 ++++++ cmd/creator-web/static/assets/console.css | 163 +++++++++++++ cmd/creator-web/static/assets/console.js | 215 ++++++++++++++++++ cmd/creator-web/static/index.html | 132 +++++++++++ deploy/docker-compose.dev.yml | 11 + deploy/docker-compose.prod.yml | 14 ++ dev/tasks/DEV-000025-creator-web-console.json | 16 ++ tests/creator_web/verify-remote.ps1 | 33 +++ 10 files changed, 843 insertions(+) create mode 100644 cmd/creator-web/main.go create mode 100644 cmd/creator-web/server.go create mode 100644 cmd/creator-web/server_test.go create mode 100644 cmd/creator-web/static/assets/console.css create mode 100644 cmd/creator-web/static/assets/console.js create mode 100644 cmd/creator-web/static/index.html create mode 100644 dev/tasks/DEV-000025-creator-web-console.json create mode 100644 tests/creator_web/verify-remote.ps1 diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go new file mode 100644 index 0000000..31287f0 --- /dev/null +++ b/cmd/creator-web/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" +) + +func main() { + addr := envOr("LISTEN_ADDR", ":8084") + handler, err := newHandler(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 10 * time.Second}) + if err != nil { + log.Fatal(err) + } + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("creator-web listening on %s", addr) + if err := server.ListenAndServe(); err != nil { + log.Fatal(err) + } +} + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go new file mode 100644 index 0000000..b1cd8c3 --- /dev/null +++ b/cmd/creator-web/server.go @@ -0,0 +1,156 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "strings" +) + +const ( + maxRequestBytes = 8 << 10 + maxResponseBytes = 1 << 20 +) + +//go:embed static +var staticFiles embed.FS + +type gateway struct { + upstream *url.URL + client *http.Client + static http.Handler +} + +func newHandler(upstream string, client *http.Client) (http.Handler, error) { + parsed, err := url.Parse(upstream) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid core runtime URL") + } + assets, err := fs.Sub(staticFiles, "static") + if err != nil { + return nil, fmt.Errorf("load embedded console: %w", err) + } + if client == nil { + client = http.DefaultClient + } + return securityHeaders(&gateway{upstream: parsed, client: client, static: http.FileServer(http.FS(assets))}), nil +} + +func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "runtime": "creator-web"}) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { + g.forwardAPI(w, r) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if r.URL.Path == "/" { + w.Header().Set("Cache-Control", "no-store") + } + g.static.ServeHTTP(w, r) +} + +func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) { + upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path) + if !allowed { + http.Error(w, "creator route denied", http.StatusNotFound) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes+1)) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if len(body) > maxRequestBytes { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + target := *g.upstream + target.Path = upstreamPath + target.RawQuery = r.URL.RawQuery + request, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), strings.NewReader(string(body))) + if err != nil { + http.Error(w, "creator gateway unavailable", http.StatusBadGateway) + return + } + copyRequestHeader(request.Header, r.Header, "Authorization") + copyRequestHeader(request.Header, r.Header, "Accept") + copyRequestHeader(request.Header, r.Header, "Content-Type") + response, err := g.client.Do(request) + if err != nil { + http.Error(w, "creator gateway unavailable", http.StatusBadGateway) + return + } + defer response.Body.Close() + copyResponseHeader(w.Header(), response.Header, "Content-Type") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes)) +} + +func creatorRoute(method, path string) (string, bool) { + switch { + case method == http.MethodPost && path == "/api/session": + return "/v1/creator/sessions", true + case method == http.MethodGet && path == "/api/session/current": + return "/v1/creator/sessions/current", true + case method == http.MethodGet && path == "/api/focus": + return "/v1/creator/focus", true + case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"): + id := strings.TrimPrefix(path, "/api/tasks/") + if validID(id) { + return "/v1/creator/tasks/" + id, true + } + case method == http.MethodGet && strings.HasPrefix(path, "/api/capabilities/") && strings.HasSuffix(path, "/health"): + id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/capabilities/"), "/health") + if validID(id) { + return "/v1/creator/capabilities/" + id + "/health", true + } + } + return "", false +} + +func validID(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("._:-", char) { + continue + } + return false + } + return true +} + +func copyRequestHeader(destination, source http.Header, name string) { + if value := source.Get(name); value != "" { + destination.Set(name, value) + } +} + +func copyResponseHeader(destination, source http.Header, name string) { + if value := source.Get(name); value != "" { + destination.Set(name, value) + } +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go new file mode 100644 index 0000000..e0ceb1d --- /dev/null +++ b/cmd/creator-web/server_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/tasks/task-1" || r.URL.RawQuery != "session_id=session-1&workspace_id=workspace-1" { + t.Fatalf("unexpected upstream target %s?%s", r.URL.Path, r.URL.RawQuery) + } + if r.Header.Get("Authorization") != "Bearer bounded-session" { + t.Fatal("authorization header was not forwarded") + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"TaskID":"task-1"}`) + })) + defer upstream.Close() + handler, err := newHandler(upstream.URL, upstream.Client()) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/tasks/task-1?session_id=session-1&workspace_id=workspace-1", nil) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "task-1") { + t.Fatalf("response = %d %s", response.Code, response.Body.String()) + } +} + +func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) { + handler, err := newHandler("http://core-runtime:8080", http.DefaultClient) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct{ method, path string }{ + {http.MethodPost, "/api/tasks/task-1"}, + {http.MethodGet, "/api/principal-bindings/actor/principal"}, + {http.MethodPost, "/api/focus-task-commits"}, + {http.MethodGet, "/api/tasks/../../healthz"}, + } { + request := httptest.NewRequest(test.method, test.path, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("%s %s returned %d", test.method, test.path, response.Code) + } + } +} + +func TestConsoleAndSecurityHeaders(t *testing.T) { + handler, err := newHandler("http://core-runtime:8080", http.DefaultClient) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") { + t.Fatalf("console response = %d", response.Code) + } + if response.Header().Get("Content-Security-Policy") == "" { + t.Fatal("content security policy is missing") + } +} diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css new file mode 100644 index 0000000..f181c73 --- /dev/null +++ b/cmd/creator-web/static/assets/console.css @@ -0,0 +1,163 @@ +:root { + --ink: #e9eee7; + --muted: #8c9a91; + --faint: #58645d; + --base: #0b100e; + --panel: #101714; + --panel-high: #151e1a; + --line: #2a352f; + --line-soft: #1d2822; + --signal: #c9a85f; + --signal-bright: #e7c97b; + --good: #78c6a3; + --bad: #de816f; + --warn: #dfb565; + --radius: 3px; + --shadow: 0 24px 80px rgba(0, 0, 0, .34); + --sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif; + --serif: Georgia, "Times New Roman", serif; + --mono: "Cascadia Mono", "IBM Plex Mono", Consolas, monospace; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html { background: var(--base); color: var(--ink); font-family: var(--sans); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .15), transparent 38%), var(--base); } +button, input { font: inherit; } +button { color: inherit; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; } + +.grain { position: fixed; inset: 0; pointer-events: none; opacity: .18; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); } +.eyebrow, .rail-index { margin: 0 0 12px; color: var(--signal); font: 600 11px/1 var(--mono); letter-spacing: .18em; text-transform: uppercase; } + +.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; position: relative; overflow: hidden; } +.login-shell::before, .login-shell::after { content: ""; position: absolute; border: 1px solid var(--line-soft); border-radius: 50%; } +.login-shell::before { width: 58vw; height: 58vw; min-width: 620px; min-height: 620px; } +.login-shell::after { width: 38vw; height: 38vw; min-width: 420px; min-height: 420px; border-style: dashed; animation: orbit 80s linear infinite; } +.login-panel { position: relative; z-index: 1; width: min(100%, 440px); padding: 54px 48px 46px; background: rgba(16, 23, 20, .94); border: 1px solid var(--line); box-shadow: var(--shadow); } +.brand-mark { width: 50px; height: 50px; margin-bottom: 34px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 27px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); } +.login-panel h1 { margin: 0; font: 400 clamp(44px, 9vw, 65px)/.95 var(--serif); letter-spacing: -.04em; } +.login-copy { margin: 24px 0 34px; max-width: 35ch; color: var(--muted); font-size: 14px; line-height: 1.55; } +.login-form label, .context-form label { display: block; margin: 0 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.login-form input, .context-form input { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; transition: border-color .18s, background .18s; } +.login-form input:hover, .context-form input:hover { border-color: var(--faint); } +.login-form input { height: 52px; padding: 0 50px 0 14px; font-family: var(--mono); } +.secret-row { position: relative; } +.secret-row .icon-button { position: absolute; right: 7px; top: 7px; } +.icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); background: transparent; border-radius: var(--radius); cursor: pointer; transition: background .18s, border-color .18s; } +.icon-button:hover { background: var(--panel-high); border-color: var(--faint); } +.primary-button, .secondary-button { border: 0; border-radius: var(--radius); cursor: pointer; font-weight: 650; transition: transform .18s, filter .18s; } +.primary-button { width: 100%; height: 52px; margin-top: 14px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; color: #11150f; background: var(--signal); } +.primary-button:hover, .secondary-button:hover { filter: brightness(1.12); transform: translateY(-1px); } +.primary-button:disabled, .secondary-button:disabled { cursor: wait; filter: saturate(.3); transform: none; } +.form-error { min-height: 20px; margin: 10px 0 0; color: var(--bad); font-size: 12px; } +.trust-line { margin-top: 30px; display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: center; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.trust-line span { height: 1px; background: var(--line); } + +.console { min-height: 100vh; display: grid; grid-template-columns: 280px minmax(0, 1fr); grid-template-rows: 74px 1fr; } +.topbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; padding: 0 26px; border-bottom: 1px solid var(--line); background: rgba(11, 16, 14, .92); backdrop-filter: blur(14px); position: sticky; top: 0; z-index: 5; } +.wordmark { display: flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; } +.wordmark-symbol { width: 36px; height: 36px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 20px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); } +.wordmark strong, .wordmark small { display: block; } +.wordmark strong { font: 400 20px/1 var(--serif); } +.wordmark small { margin-top: 4px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.topbar-actions { display: flex; align-items: center; gap: 10px; } +.connection { margin-right: 12px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.connection i { width: 7px; height: 7px; display: inline-block; margin-right: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 14px rgba(120, 198, 163, .6); } +.connection.offline i { background: var(--bad); box-shadow: none; } +.text-button { border: 0; padding: 10px 12px; color: var(--muted); background: transparent; cursor: pointer; font: 600 10px/1 var(--mono); letter-spacing: .09em; text-transform: uppercase; } +.text-button:hover { color: var(--ink); } + +.context-rail { grid-column: 1; grid-row: 2; padding: 34px 26px 28px; display: flex; flex-direction: column; gap: 34px; border-right: 1px solid var(--line); background: #0e1512; } +.context-rail h2 { margin: 0; font: 400 29px/1.1 var(--serif); } +.rail-copy { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.context-form { display: grid; } +.context-form label { margin-top: 15px; } +.context-form label:first-child { margin-top: 0; } +.context-form input { height: 40px; padding: 0 10px; font: 11px/1 var(--mono); } +.secondary-button { height: 42px; margin-top: 20px; color: #11150f; background: var(--signal); font-size: 12px; } +.session-stamp { margin-top: auto; padding-top: 20px; display: grid; gap: 6px; border-top: 1px solid var(--line); } +.session-stamp span { margin-top: 8px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; } +.session-stamp strong { overflow: hidden; color: var(--muted); font: 10px/1.4 var(--mono); text-overflow: ellipsis; } + +.workspace { grid-column: 2; grid-row: 2; padding: clamp(28px, 4vw, 58px); min-width: 0; } +.workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; } +.workspace-head h1 { margin: 0; font: 400 clamp(36px, 5vw, 58px)/1 var(--serif); letter-spacing: -.035em; } +.last-update { color: var(--faint); font: 10px/1.4 var(--mono); text-align: right; } +.signal-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); } +.signal-strip div { padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-right: 1px solid var(--line); } +.signal-strip div:last-child { border: 0; } +.signal-strip span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; } +.signal-strip strong { color: var(--muted); font: 600 10px/1 var(--mono); text-transform: uppercase; } +.signal-strip strong[data-state="ok"] { color: var(--good); } +.signal-strip strong[data-state="error"] { color: var(--bad); } +.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; } + +.data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; } +.task-card { animation-delay: .08s; } +.capability-card { grid-column: 1 / -1; animation-delay: .16s; } +.data-card header { display: grid; grid-template-columns: 30px 1fr auto; gap: 12px; align-items: start; margin-bottom: 26px; } +.card-number { color: var(--signal); font: 10px/1.5 var(--mono); } +.data-card header p { margin: 0 0 5px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; } +.data-card h2 { margin: 0; font: 400 22px/1 var(--serif); } +.status-badge { align-self: center; padding: 6px 8px; border: 1px solid currentColor; border-radius: 100px; font: 600 8px/1 var(--mono); letter-spacing: .08em; } +.status-badge.neutral { color: var(--faint); } +.status-badge.good { color: var(--good); } +.status-badge.bad { color: var(--bad); } +.status-badge.warn { color: var(--warn); } +.data-card dl { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--line-soft); } +.data-card dl div { min-width: 0; padding: 15px 0; border-bottom: 1px solid var(--line-soft); } +.data-card dl div:nth-child(odd) { padding-right: 18px; } +.data-card dt { margin-bottom: 7px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.data-card dd { margin: 0; overflow: hidden; color: var(--ink); font: 11px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.card-message { min-height: 18px; margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; } +.capability-core { display: grid; grid-template-columns: 110px 1fr; gap: 28px; align-items: center; padding: 8px 0 26px; } +.capability-core > div:last-child > span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; } +.capability-core strong { display: block; margin: 8px 0; font: 400 clamp(30px, 5vw, 46px)/1 var(--serif); } +.capability-core small { color: var(--muted); font-size: 11px; } +.radar { width: 96px; height: 96px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; position: relative; background: radial-gradient(circle, rgba(120,198,163,.16), transparent 63%); } +.radar::before, .radar::after { content: ""; position: absolute; background: var(--line); } +.radar::before { width: 100%; height: 1px; } +.radar::after { width: 1px; height: 100%; } +.radar span { width: 52%; height: 52%; border: 1px dashed var(--faint); border-radius: 50%; } +.radar i { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 16px var(--good); } +.compact-list { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; } +.compact-list div { padding-right: 18px !important; } +.toast { position: fixed; right: 22px; bottom: 22px; z-index: 20; max-width: min(390px, calc(100vw - 44px)); padding: 14px 16px; color: var(--ink); background: #222e28; border-left: 3px solid var(--signal); box-shadow: var(--shadow); font-size: 12px; } + +@keyframes reveal { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes pulse { 50% { opacity: .45; } } +@keyframes orbit { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +@media (max-width: 900px) { + .console { grid-template-columns: 230px minmax(0, 1fr); } + .context-rail { padding-inline: 20px; } + .workspace { padding: 32px 24px; } + .data-grid { grid-template-columns: 1fr; } + .capability-card { grid-column: auto; } +} + +@media (max-width: 680px) { + .login-panel { padding: 42px 26px 36px; } + .console { display: block; } + .topbar { height: 66px; padding: 0 14px; } + .wordmark small, .connection, .text-button { display: none; } + .context-rail { padding: 24px 18px; border-right: 0; border-bottom: 1px solid var(--line); } + .rail-copy { max-width: 48ch; } + .session-stamp { margin-top: 8px; grid-template-columns: auto 1fr; align-items: center; } + .session-stamp span { margin: 0; } + .workspace { padding: 30px 16px 50px; } + .workspace-head { align-items: start; flex-direction: column; } + .last-update { text-align: left; } + .signal-strip { grid-template-columns: 1fr; } + .signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); } + .data-card { padding: 20px 17px; } + .data-card header { grid-template-columns: 24px 1fr; } + .status-badge { grid-column: 2; justify-self: start; } + .data-card dl, .compact-list { grid-template-columns: 1fr !important; } + .data-card dl div { padding-right: 0 !important; } + .capability-core { grid-template-columns: 76px 1fr; gap: 18px; } + .radar { width: 72px; height: 72px; } +} diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js new file mode 100644 index 0000000..215013b --- /dev/null +++ b/cmd/creator-web/static/assets/console.js @@ -0,0 +1,215 @@ +const byId = (id) => document.getElementById(id); +const loginView = byId("login-view"); +const consoleView = byId("console-view"); +const loginForm = byId("login-form"); +const contextForm = byId("context-form"); +const loginError = byId("login-error"); +const toast = byId("toast"); +const contextFields = ["session-id", "workspace-id", "task-id", "capability-id"]; +let sessionToken = sessionStorage.getItem("domovoy.creator.session") || ""; +let toastTimer; + +function setText(id, value, fallback = "—") { + byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value); +} + +function setSignal(name, text, state) { + const signal = byId(`${name}-signal`); + signal.textContent = text; + signal.dataset.state = state; +} + +function setBadge(name, text, tone) { + const badge = byId(`${name}-badge`); + badge.textContent = text; + badge.className = `status-badge ${tone}`; +} + +function notify(message) { + toast.textContent = message; + toast.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { toast.hidden = true; }, 4200); +} + +async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`); + headers.set("Accept", "application/json"); + const response = await fetch(path, {...options, headers}); + if (response.status === 401 && path !== "/api/session") { + endSession("Сессия истекла. Войдите снова."); + throw new Error("Сессия отклонена"); + } + if (!response.ok) { + const detail = (await response.text()).trim(); + throw new Error(detail || `Ошибка ${response.status}`); + } + return response.json(); +} + +function restoreContext() { + try { + const saved = JSON.parse(localStorage.getItem("domovoy.creator.context") || "{}"); + contextFields.forEach((id) => { + if (saved[id]) byId(id).value = saved[id]; + }); + } catch (_) { + localStorage.removeItem("domovoy.creator.context"); + } +} + +function saveContext() { + const saved = {}; + contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); }); + localStorage.setItem("domovoy.creator.context", JSON.stringify(saved)); + return saved; +} + +async function showConsole() { + const session = await request("/api/session/current"); + setText("session-actor", session.ActorID); + const expiry = session.ExpiresAtUnix ? new Date(session.ExpiresAtUnix * 1000).toLocaleString("ru-RU") : "—"; + setText("session-expiry", expiry); + loginView.hidden = true; + consoleView.hidden = false; + restoreContext(); + const context = saveContext(); + if (context["session-id"] && context["workspace-id"]) loadDashboard(); +} + +function endSession(message = "Сессия завершена.") { + sessionToken = ""; + sessionStorage.removeItem("domovoy.creator.session"); + consoleView.hidden = true; + loginView.hidden = false; + loginForm.reset(); + loginError.textContent = ""; + notify(message); +} + +loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = byId("login-button"); + const bootstrap = byId("bootstrap-token").value.trim(); + loginError.textContent = ""; + button.disabled = true; + button.firstElementChild.textContent = "Проверяю ключ…"; + try { + const result = await request("/api/session", {method: "POST", headers: {Authorization: `Bearer ${bootstrap}`}}); + sessionToken = result.Token; + sessionStorage.setItem("domovoy.creator.session", sessionToken); + byId("bootstrap-token").value = ""; + await showConsole(); + } catch (error) { + loginError.textContent = error.message === "creator authentication denied" ? "Ключ не принят." : error.message; + } finally { + button.disabled = false; + button.firstElementChild.textContent = "Открыть консоль"; + } +}); + +byId("toggle-secret").addEventListener("click", () => { + const input = byId("bootstrap-token"); + input.type = input.type === "password" ? "text" : "password"; + byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать ключ" : "Скрыть ключ"); +}); + +contextForm.addEventListener("submit", (event) => { + event.preventDefault(); + saveContext(); + loadDashboard(); +}); + +byId("refresh-all").addEventListener("click", loadDashboard); +byId("logout").addEventListener("click", () => endSession()); + +async function loadDashboard() { + const context = saveContext(); + const sessionID = context["session-id"]; + const workspaceID = context["workspace-id"]; + const query = new URLSearchParams({session_id: sessionID, workspace_id: workspaceID}); + byId("connection-state").classList.remove("offline"); + setText("last-update", "Обновление…"); + + const work = []; + if (sessionID && workspaceID) work.push(loadFocus(query)); + else { + setSignal("focus", "нет контекста", "idle"); + setBadge("focus", "НЕ ЗАГРУЖЕН", "neutral"); + setText("focus-message", "Введите идентификаторы сессии и рабочей области."); + } + if (sessionID && workspaceID && context["task-id"]) work.push(loadTask(context["task-id"], query)); + else { + setSignal("task", "нет задачи", "idle"); + setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral"); + setText("task-message", "Укажите задачу для проверки относительно активного фокуса."); + } + if (context["capability-id"]) work.push(loadCapability(context["capability-id"])); + else setSignal("capability", "не выбрана", "idle"); + + await Promise.allSettled(work); + setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`); +} + +async function loadFocus(query) { + setSignal("focus", "загрузка", "loading"); + try { + const focus = await request(`/api/focus?${query}`); + setText("focus-project", focus.ProjectID); + setText("focus-goal", focus.GoalID); + setText("focus-scope", focus.ScopeID); + setText("focus-binding", focus.ID); + setText("focus-message", "Фокус принадлежит текущему Actor и прошёл проверку сессии."); + setSignal("focus", "активен", "ok"); + setBadge("focus", focus.Active ? "АКТИВЕН" : "НЕАКТИВЕН", focus.Active ? "good" : "warn"); + } catch (error) { + setSignal("focus", "ошибка", "error"); + setBadge("focus", "НЕДОСТУПЕН", "bad"); + setText("focus-message", error.message); + byId("connection-state").classList.add("offline"); + } +} + +async function loadTask(taskID, query) { + setSignal("task", "загрузка", "loading"); + try { + const task = await request(`/api/tasks/${encodeURIComponent(taskID)}?${query}`); + setText("task-value", task.TaskID); + setText("task-goal", task.GoalID); + setText("task-scope", task.ScopeID); + setText("task-revision", task.Revision); + setText("task-message", "Цель и область задачи совпадают с активным фокусом."); + setSignal("task", "в фокусе", "ok"); + setBadge("task", "ПРОВЕРЕНА", "good"); + } catch (error) { + setSignal("task", "отклонена", "error"); + setBadge("task", "ВНЕ ФОКУСА", "bad"); + setText("task-message", error.message); + } +} + +async function loadCapability(capabilityID) { + setSignal("capability", "загрузка", "loading"); + try { + const health = await request(`/api/capabilities/${encodeURIComponent(capabilityID)}/health`); + const state = health.State || "UNKNOWN"; + const tone = state === "AVAILABLE" ? "good" : state === "DEGRADED" ? "warn" : "bad"; + setText("capability-state", state); + setText("capability-reason", health.Reason, "NONE"); + setText("capability-value", health.CapabilityID); + setText("capability-revision", health.Revision); + setText("capability-observed", health.ObservedAtUnixMillis ? new Date(health.ObservedAtUnixMillis).toLocaleString("ru-RU") : "не указано"); + setSignal("capability", state.toLowerCase(), tone === "bad" ? "error" : "ok"); + setBadge("capability", state, tone); + } catch (error) { + setSignal("capability", "ошибка", "error"); + setBadge("capability", "НЕДОСТУПНА", "bad"); + setText("capability-state", "UNKNOWN"); + setText("capability-reason", error.message); + } +} + +if (sessionToken) { + showConsole().catch(() => endSession("Сохранённая сессия больше не действует.")); +} diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html new file mode 100644 index 0000000..fb9b38c --- /dev/null +++ b/cmd/creator-web/static/index.html @@ -0,0 +1,132 @@ + + + + + + + + Домовой — консоль создателя + + + + + +
+ +
+ + + + + + + diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index ef71506..01323e0 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -91,5 +91,16 @@ services: environment: { LISTEN_ADDR: ":8083" } ports: ["19083:8083"] + creator-web: + build: + context: .. + dockerfile: deploy/images/runtime.Dockerfile + args: { TARGET: creator-web } + environment: + LISTEN_ADDR: ":8084" + CORE_RUNTIME_URL: http://core-runtime:8080 + ports: ["19084:8084"] + depends_on: [core-runtime] + volumes: assistant_pg_dev: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 42b53bf..bd2ffb7 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -81,6 +81,20 @@ services: restart: unless-stopped environment: { LISTEN_ADDR: ":8083" } + creator-web: + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web } } + restart: unless-stopped + environment: + LISTEN_ADDR: ":8084" + CORE_RUNTIME_URL: http://core-runtime:8080 + ports: ["${CREATOR_WEB_PORT:-18080}:8084"] + depends_on: [core-runtime] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"] + interval: 10s + timeout: 3s + retries: 10 + volumes: assistant_pg: assistant_nats: diff --git a/dev/tasks/DEV-000025-creator-web-console.json b/dev/tasks/DEV-000025-creator-web-console.json new file mode 100644 index 0000000..c336229 --- /dev/null +++ b/dev/tasks/DEV-000025-creator-web-console.json @@ -0,0 +1,16 @@ +{ + "task_id": "DEV-000025", + "task_revision": 1, + "architecture_revision": "bootstrap-1", + "base_repo_revision": "cef59b709e7f39fb4b90ec6c1609b206833b0de6", + "risk_tier": "C", + "intent": "Provide a production Docker-hosted Domovoy Creator web console through a narrow gateway that exposes only the authenticated read API from DEV-000024.", + "allowed_change_scope": {"paths": ["cmd/creator-web/**", "deploy/docker-compose.prod.yml", "deploy/docker-compose.dev.yml", "tests/creator_web/**", "dev/tasks/DEV-000025-creator-web-console.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "contracts/**", "adapters/**", "cmd/core-runtime/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0}, + "semantic_owner": "none-interface-adapter", + "assumptions": ["Port 18080 is currently unused on docker.cin.su", "Creator bootstrap credentials remain runtime-only and are never persisted by the browser"], + "acceptance_scenarios": ["Creator can exchange a bootstrap credential for a bounded session", "the gateway exposes only allowlisted Creator endpoints", "Creator can inspect own Focus, focus-matched Task, and Capability Health", "the console is usable on desktop and mobile", "no internal mutation endpoint is externally reachable"], + "required_tests": ["unit", "gateway allowlist", "runtime HTTP", "responsive browser", "architecture", "remote Docker smoke"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/creator_web/verify-remote.ps1 b/tests/creator_web/verify-remote.ps1 new file mode 100644 index 0000000..482f092 --- /dev/null +++ b/tests/creator_web/verify-remote.ps1 @@ -0,0 +1,33 @@ +param( + [string]$DockerContext = "docker-prod", + [string]$BaseURL = "http://docker.cin.su:18080" +) + +$ErrorActionPreference = "Stop" +$coreContainer = "assistant-core-runtime-1" + +$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get +if ($health.status -ne "ok" -or $health.runtime -ne "creator-web") { throw "Creator web health response is invalid" } + +$page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get +if ($page.StatusCode -ne 200 -or $page.Content -notmatch "Домовой") { throw "Creator console is not available" } +if (-not $page.Headers["Content-Security-Policy"]) { throw "Creator console security headers are missing" } + +$denied = Invoke-WebRequest -Uri "$BaseURL/api/focus-task-commits" -Method Post -SkipHttpErrorCheck +if ($denied.StatusCode -ne 404) { throw "Internal mutation route was exposed" } +$invalid = Invoke-WebRequest -Uri "$BaseURL/api/session/current" -Headers @{Authorization="Bearer invalid"} -SkipHttpErrorCheck +if ($invalid.StatusCode -ne 401) { throw "Invalid Creator session was not denied" } + +$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer +$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1 +if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" } +$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length) +$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"} +if (-not $issued.Token) { throw "Creator session was not issued through the gateway" } +$current = Invoke-RestMethod -Uri "$BaseURL/api/session/current" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"} +if ($current.ActorID -ne "domovoy-creator-actor") { throw "Creator identity was not preserved through the gateway" } + +$capability = Invoke-RestMethod -Uri "$BaseURL/api/capabilities/cognitive-proposal/health" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"} +if ($capability.CapabilityID -ne "cognitive-proposal") { throw "Capability health was not returned through the gateway" } + +Write-Output "DEV-000025 remote Creator web console: PASS"