This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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("Сохранённая сессия больше не действует."));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#101714">
|
||||
<title>Домовой — консоль создателя</title>
|
||||
<link rel="stylesheet" href="/assets/console.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
|
||||
<main id="login-view" class="login-shell">
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<div class="brand-mark" aria-hidden="true">Д</div>
|
||||
<p class="eyebrow">защищённый вход · creator</p>
|
||||
<h1 id="login-title">Домовой</h1>
|
||||
<p class="login-copy">Личная операторская консоль. Ключ используется только для выпуска ограниченной сессии и не сохраняется браузером.</p>
|
||||
<form id="login-form" class="login-form">
|
||||
<label for="bootstrap-token">Ключ создателя</label>
|
||||
<div class="secret-row">
|
||||
<input id="bootstrap-token" name="token" type="password" autocomplete="current-password" required autofocus>
|
||||
<button class="icon-button" id="toggle-secret" type="button" aria-label="Показать ключ" title="Показать ключ">
|
||||
<span aria-hidden="true">◉</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="login-button" class="primary-button" type="submit">
|
||||
<span>Открыть консоль</span><span aria-hidden="true">→</span>
|
||||
</button>
|
||||
<p id="login-error" class="form-error" role="alert"></p>
|
||||
</form>
|
||||
<div class="trust-line"><span></span>Сессия действует 8 часов<span></span></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="console-view" class="console" hidden>
|
||||
<header class="topbar">
|
||||
<a class="wordmark" href="/" aria-label="Домовой, главная">
|
||||
<span class="wordmark-symbol">Д</span>
|
||||
<span><strong>Домовой</strong><small>консоль создателя</small></span>
|
||||
</a>
|
||||
<div class="topbar-actions">
|
||||
<span id="connection-state" class="connection"><i></i>система доступна</span>
|
||||
<button id="refresh-all" class="text-button" type="button">Обновить</button>
|
||||
<button id="logout" class="icon-button" type="button" aria-label="Завершить сессию" title="Выйти">↗</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="context-rail">
|
||||
<div>
|
||||
<p class="rail-index">01 / КОНТЕКСТ</p>
|
||||
<h2>Точка внимания</h2>
|
||||
<p class="rail-copy">Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.</p>
|
||||
</div>
|
||||
<form id="context-form" class="context-form">
|
||||
<label for="session-id">Идентификатор сессии</label>
|
||||
<input id="session-id" autocomplete="off" placeholder="session-id">
|
||||
<label for="workspace-id">Рабочая область</label>
|
||||
<input id="workspace-id" autocomplete="off" placeholder="workspace-id">
|
||||
<label for="task-id">Задача</label>
|
||||
<input id="task-id" autocomplete="off" placeholder="task-id">
|
||||
<label for="capability-id">Возможность</label>
|
||||
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
|
||||
<button class="secondary-button" type="submit">Загрузить контекст</button>
|
||||
</form>
|
||||
<div class="session-stamp">
|
||||
<span>ACTOR</span>
|
||||
<strong id="session-actor">—</strong>
|
||||
<span>СЕССИЯ ДО</span>
|
||||
<strong id="session-expiry">—</strong>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="workspace-head">
|
||||
<div>
|
||||
<p class="eyebrow">операционная картина</p>
|
||||
<h1>Состояние сейчас</h1>
|
||||
</div>
|
||||
<p id="last-update" class="last-update">Данные ещё не запрошены</p>
|
||||
</section>
|
||||
|
||||
<section class="signal-strip" aria-label="Сводка состояния">
|
||||
<div><span>ФОКУС</span><strong id="focus-signal" data-state="idle">ожидает</strong></div>
|
||||
<div><span>ЗАДАЧА</span><strong id="task-signal" data-state="idle">ожидает</strong></div>
|
||||
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="data-grid">
|
||||
<article class="data-card focus-card">
|
||||
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
|
||||
<dl>
|
||||
<div><dt>Проект</dt><dd id="focus-project">—</dd></div>
|
||||
<div><dt>Цель</dt><dd id="focus-goal">—</dd></div>
|
||||
<div><dt>Область</dt><dd id="focus-scope">—</dd></div>
|
||||
<div><dt>Привязка</dt><dd id="focus-binding">—</dd></div>
|
||||
</dl>
|
||||
<p id="focus-message" class="card-message">Введите идентификаторы сессии и рабочей области.</p>
|
||||
</article>
|
||||
|
||||
<article class="data-card task-card">
|
||||
<header><span class="card-number">02</span><div><p>Work</p><h2>Задача в фокусе</h2></div><span id="task-badge" class="status-badge neutral">НЕ ЗАГРУЖЕНА</span></header>
|
||||
<dl>
|
||||
<div><dt>Задача</dt><dd id="task-value">—</dd></div>
|
||||
<div><dt>Цель</dt><dd id="task-goal">—</dd></div>
|
||||
<div><dt>Область</dt><dd id="task-scope">—</dd></div>
|
||||
<div><dt>Ревизия</dt><dd id="task-revision">—</dd></div>
|
||||
</dl>
|
||||
<p id="task-message" class="card-message">Укажите задачу для проверки относительно активного фокуса.</p>
|
||||
</article>
|
||||
|
||||
<article class="data-card capability-card">
|
||||
<header><span class="card-number">03</span><div><p>Capability</p><h2>Готовность</h2></div><span id="capability-badge" class="status-badge neutral">НЕИЗВЕСТНО</span></header>
|
||||
<div class="capability-core">
|
||||
<div class="radar" aria-hidden="true"><span></span><i></i></div>
|
||||
<div><span>ТЕКУЩЕЕ СОСТОЯНИЕ</span><strong id="capability-state">—</strong><small id="capability-reason">Причина не получена</small></div>
|
||||
</div>
|
||||
<dl class="compact-list">
|
||||
<div><dt>Возможность</dt><dd id="capability-value">—</dd></div>
|
||||
<div><dt>Ревизия</dt><dd id="capability-revision">—</dd></div>
|
||||
<div><dt>Наблюдалось</dt><dd id="capability-observed">—</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<script src="/assets/console.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user