diff --git a/adapters/postgres/identity_repository.go b/adapters/postgres/identity_repository.go index d03ef79..65ae6d7 100644 --- a/adapters/postgres/identity_repository.go +++ b/adapters/postgres/identity_repository.go @@ -26,3 +26,39 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act } return binding, nil } + +func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) { + var account identity.CreatorAccount + err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username). + Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision) + if err != nil { + return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err) + } + return account, nil +} + +func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) { + var account identity.CreatorAccount + err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal). + Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision) + if err != nil { + return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err) + } + return account, nil +} + +func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error { + _, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash) + return err +} + +func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error { + result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision) + if err != nil { + return fmt.Errorf("update creator password: %w", err) + } + if result.RowsAffected() != 1 { + return fmt.Errorf("update creator password: revision conflict") + } + return nil +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 4b0921e..dae9bab 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -37,6 +37,14 @@ import ( "time" ) +const ( + creatorPasswordPath = "/run/secrets/creator_password" + creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token" + creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" + epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" + interactionDBPasswordPath = "/run/secrets/interaction_db_password" +) + const maxCreatorCredentialsBytes = 4 << 10 func main() { @@ -44,20 +52,19 @@ func main() { if addr == "" { addr = ":8080" } - bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN") + bootstrapToken, err := readRequiredSecretFile("CREATOR_BOOTSTRAP_TOKEN", creatorBootstrapTokenPath) if err != nil { log.Fatal(err) } - sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY") - if err != nil { - log.Fatal(err) - } - creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD") + sessionKeyValue, err := readRequiredSecretFile("CREATOR_SESSION_SIGNING_KEY", creatorSessionKeyPath) if err != nil { log.Fatal(err) } sessionKey := []byte(sessionKeyValue) creatorUsername := os.Getenv("CREATOR_USERNAME") + if creatorUsername == "" { + log.Fatal("CREATOR_USERNAME is required") + } repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")}) if err != nil { log.Fatal(err) @@ -73,7 +80,7 @@ func main() { log.Fatal(err) } defer capabilityRepository.Close() - epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF") + epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF", epistemicsDBPasswordPath) if err != nil { log.Fatal(err) } @@ -91,7 +98,10 @@ func main() { log.Fatal(err) } defer identityRepository.Close() - interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF") + if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil { + log.Fatal(err) + } + interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath) if err != nil { log.Fatal(err) } @@ -177,7 +187,39 @@ func main() { } w.WriteHeader(http.StatusNoContent) }) - mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now)) + mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now)) + mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + var request struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" { + http.Error(w, "invalid password change request", http.StatusBadRequest) + return + } + account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID) + if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) { + http.Error(w, "current password denied", http.StatusUnauthorized) + return + } + newHash, err := identitydomain.HashCreatorPassword(request.NewPassword) + if err != nil { + http.Error(w, "invalid new password", http.StatusBadRequest) + return + } + if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil { + http.Error(w, "password change conflict", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"}) + }) mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) if !ok { @@ -1704,6 +1746,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri } } +func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) { + token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) + return + } + writeCreatorSession(w, token, session) + return + } + var credentials identity.CreatorCredentials + decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username) + if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID) + if err != nil || !binding.Active { + http.Error(w, "creator authentication denied", http.StatusUnauthorized) + return + } + token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusServiceUnavailable) + return + } + writeCreatorSession(w, token, session) + } +} + +func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Token string + Session identity.CreatorSession + }{token, session}) +} + +func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error { + if account, err := repository.LoadCreatorAccount(ctx, username); err == nil { + if account.ActorID == "" || account.PrincipalID == "" { + return errors.New("creator account binding is incomplete") + } + return nil + } else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) { + return err + } + password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath) + if err != nil { + return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err) + } + hash, err := identitydomain.HashCreatorPassword(password) + if err != nil { + return fmt.Errorf("hash creator bootstrap password: %w", err) + } + if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil { + return fmt.Errorf("create creator account: %w", err) + } + return nil +} + +func unwrapIdentityRepositoryError(err error) error { + for err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return pgx.ErrNoRows + } + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + break + } + err = unwrapped + } + return err +} + func bearerToken(r *http.Request) string { value := r.Header.Get("Authorization") if !strings.HasPrefix(value, "Bearer ") { @@ -1726,10 +1850,7 @@ func readRequiredSecret(name string) (string, error) { if err != nil { return "", fmt.Errorf("read %s_FILE: %w", name, err) } - direct = strings.TrimRight(string(contents), "\r\n") - if strings.ContainsAny(direct, "\r\n") { - return "", fmt.Errorf("%s_FILE must contain one line", name) - } + return normalizeSecretFile(name, contents) } if direct == "" { return "", fmt.Errorf("%s is required", name) @@ -1737,13 +1858,28 @@ func readRequiredSecret(name string) (string, error) { return direct, nil } -func readCredentialRefSecret(name, refName string) (string, error) { - if _, directSet := os.LookupEnv(name); directSet { - return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name) +func readRequiredSecretFile(name, path string) (string, error) { + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s secret file: %w", name, err) } - fileName := os.Getenv(name + "_FILE") - if fileName == "" { - return "", fmt.Errorf("%s_FILE is required", name) + return normalizeSecretFile(name, contents) +} + +func normalizeSecretFile(name string, contents []byte) (string, error) { + value := strings.TrimRight(string(contents), "\r\n") + if strings.ContainsAny(value, "\r\n") { + return "", fmt.Errorf("%s secret file must contain one line", name) + } + if value == "" { + return "", fmt.Errorf("%s is required", name) + } + return value, nil +} + +func readCredentialRefSecret(name, refName, fileName string) (string, error) { + if _, directSet := os.LookupEnv(name); directSet { + return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name) } ref := os.Getenv(refName) if ref == "" { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index fb633b6..cdd975c 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { return targetCore, "/v1/creator/sessions", true case method == http.MethodGet && path == "/api/session/current": return targetCore, "/v1/creator/sessions/current", true + case method == http.MethodPost && path == "/api/account/password": + return targetCore, "/v1/creator/account/password", true case method == http.MethodGet && path == "/api/focus": return targetCore, "/v1/creator/focus", true case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"): diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index db45089..809f2e5 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -1,148 +1,285 @@ :root { - --ink: #e9eee7; - --muted: #93a096; - --base: #0b100e; - --panel: #101714; - --line: #2a352f; - --signal: #c9a85f; - --bad: #de816f; - --good: #78c6a3; - --radius: 3px; - --sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif; - --serif: Georgia, "Times New Roman", serif; - --mono: "Cascadia Mono", Consolas, monospace; + --ink: #e6edf4; + --muted: #8494a4; + --faint: #5d6e7f; + --base: #0b1119; + --panel: #111b27; + --panel-raised: #172331; + --panel-soft: #0f1925; + --line: #263544; + --line-strong: #34485c; + --lime: #c5ef91; + --lime-ink: #142014; + --blue: #8fc7ef; + --yellow: #d8b86c; + --good: #8ed7ad; + --bad: #ee9292; + --radius: 8px; + --sans: "Manrope", "Segoe UI", sans-serif; + --mono: "DM Mono", "Cascadia Mono", Consolas, monospace; } - * { box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); } button, input, textarea { font: inherit; } button { cursor: pointer; color: inherit; border-radius: var(--radius); } +a { color: inherit; } .chat-shell { min-height: 100vh; display: grid; } -.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); } -.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); } -h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; } -label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } -input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; } -input { height: 48px; padding: 0 12px; } -textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; } -.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; } -.login-form button { width: 100%; height: 50px; margin-top: 18px; } +.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 40px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.28); } +.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--lime-ink); background: var(--lime); border-radius: 10px; font: 700 22px/1 var(--sans); } +h1, h2 { margin: 0; letter-spacing: -.03em; } +.login-panel h1 { margin: 26px 0; font-size: 42px; font-weight: 650; } +label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-soft); outline: none; } +input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.12); } +input { height: 46px; padding: 0 12px; } +textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; line-height: 1.45; } +.login-form button, .composer button { border: 0; color: var(--lime-ink); background: var(--lime); font-weight: 750; } +.login-form button { width: 100%; height: 46px; margin-top: 18px; } #login-error { min-height: 20px; color: var(--bad); font-size: 13px; } -.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; } -.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); } -.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); } -#active-focus { color: var(--good); } +.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 60px minmax(0, 1fr); overflow: hidden; } +.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(17,27,39,.97); } +.chat-header > a:first-child { color: var(--ink); text-decoration: none; font-size: 18px; font-weight: 750; letter-spacing: -.02em; } .cognitive-status { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; } -.chat-header .cognitive-status { width: auto; height: auto; padding: 0; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } -.chat-header .cognitive-status { max-width: min(30vw, 320px); margin-left: auto; overflow: hidden; text-overflow: ellipsis; } -.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--signal); } +.chat-header .cognitive-status { width: auto; height: auto; max-width: min(30vw, 340px); margin-left: auto; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .04em; text-overflow: ellipsis; text-transform: uppercase; } +.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--yellow); } .cognitive-status[data-state="available"] { color: var(--good); } .cognitive-status[data-state="available"]::before { background: var(--good); } .cognitive-status[data-state="unavailable"] { color: var(--bad); } .cognitive-status[data-state="unavailable"]::before { background: var(--bad); } -.cognitive-console-link { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; } -.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; } -.build-meta time { color: #68776d; font-size: 8px; } -.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; } -.human-nav { display: grid; gap: 8px; } -.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; } -.chat-left .nav-tab { height: auto; min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 28px 1fr; gap: 2px 8px; align-items: center; text-align: left; } -.chat-left .nav-tab b { grid-row: span 2; color: var(--signal); font: 11px/1 var(--mono); } -.chat-left .nav-tab span { color: var(--ink); font: 600 13px/1 var(--sans); text-transform: none; letter-spacing: 0; } -.chat-left .nav-tab small { color: var(--muted); font: 10px/1 var(--mono); text-transform: none; letter-spacing: 0; } -.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); } -.operator-link { color: var(--good); } -.chat-header > .operator-link { flex: 0 0 auto; } -.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(250px, 320px); } -.chat-side { min-width: 0; min-height: 0; padding: 28px 20px; overflow-y: auto; background: #0e1512; } -.generated-image { display: block; max-width: min(100%, 640px); height: auto; margin-top: 12px; border: 1px solid var(--line); } +.cognitive-console-link { color: var(--blue); font: 10px/1 var(--mono); letter-spacing: .04em; white-space: nowrap; } +.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; } +.build-meta time { color: var(--faint); font-size: 8px; } +.chat-header button { width: 34px; height: 34px; border: 1px solid var(--line); background: transparent; } +.operator-link { height: 32px; padding: 0 11px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--blue); background: transparent; text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; } +.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 258px) minmax(0, 1fr) minmax(270px, 320px); } +.chat-side { min-width: 0; min-height: 0; padding: 22px 18px; overflow-y: auto; background: var(--panel); scrollbar-color: var(--line-strong) transparent; } .chat-left { border-right: 1px solid var(--line); } .chat-right { border-left: 1px solid var(--line); } -.side-intro h1, .side-intro h2 { margin: 12px 0; font: 400 clamp(28px, 2.5vw, 40px)/.95 var(--serif); letter-spacing: -.03em; } -.side-intro p:not(.side-index) { color: var(--muted); line-height: 1.5; } -.side-index, .side-focus span, .side-note span, .ai-info-card span { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } -.side-focus, .side-note, .ai-info-card { margin-top: 28px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.side-focus strong, .side-note strong, .ai-info-card strong { display: block; margin-top: 9px; color: var(--ink); font: 600 14px/1.3 var(--sans); } -.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; } -.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; } +.side-intro h1, .side-intro h2 { margin: 9px 0 10px; font-size: clamp(25px, 2.4vw, 34px); line-height: 1.05; font-weight: 680; } +.side-intro p:not(.side-index) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.side-index, .section-label, .ai-info-card span, .insight-card span { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.side-section, .side-note, .ai-info-card { margin-top: 20px; padding: 13px 0; border-top: 1px solid var(--line); } +.side-focus strong, .side-note strong { display: block; margin-top: 8px; color: var(--blue); font-size: 13px; font-weight: 650; line-height: 1.35; } +.side-note { border-bottom: 0; } +.side-note strong { color: var(--good); } +.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.45; } +.context-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.context-list span { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; } +.quick-context { padding-bottom: 3px; } +.human-nav { display: grid; gap: 6px; margin-top: 8px; } +.nav-tab { width: 100%; min-height: 50px; padding: 8px 10px; display: grid; grid-template-columns: 26px 1fr; gap: 2px 7px; align-items: center; border: 1px solid transparent; color: var(--muted); background: transparent; text-align: left; } +.chat-left .nav-tab b { grid-row: span 2; color: var(--faint); font: 10px/1 var(--mono); } +.chat-left .nav-tab span { color: var(--ink); font-size: 12px; font-weight: 650; } +.chat-left .nav-tab small { color: var(--faint); font: 10px/1 var(--mono); } +.nav-tab:hover, .nav-tab:focus-visible { border-color: var(--line-strong); outline: none; } +.nav-tab.active { border-color: rgba(197,239,145,.42); background: rgba(197,239,145,.1); } +.nav-tab.active b { color: var(--lime); } +.quiet-state { margin: 9px 0 0; color: var(--faint); font-size: 11px; } +.new-conversation { width: 100%; min-height: 34px; margin-top: 9px; border: 1px solid var(--line-strong); color: var(--ink); background: transparent; font-size: 11px; } +.new-conversation:hover, .new-conversation:focus-visible { border-color: var(--lime); color: var(--lime); outline: none; } +.side-build { margin-top: 14px; padding-top: 14px; text-align: left; } +.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--base); } .surface-panel { min-height: 0; position: relative; overflow-y: auto; } -.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; background: radial-gradient(circle at 50% 0%, rgba(201, 168, 95, .045), transparent 42%); } -.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: clamp(28px, 5vh, 56px) 18px 24px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; scrollbar-color: #39483f transparent; } -.message { max-width: min(720px, 92%); padding: 13px 16px 14px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; } -.message.user { align-self: flex-end; background: #18221c; border-color: #3a493d; } -.message.assistant { align-self: flex-start; background: #111a15; box-shadow: 0 8px 20px rgba(0, 0, 0, .12); } -.message-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 8px; } -.message-role { color: var(--signal); font: 600 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } -.message.user .message-role { color: var(--good); } -.message-time { color: #68776d; font: 9px/1 var(--mono); } -.message-content { white-space: pre-wrap; overflow-wrap: anywhere; } -.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; } -.composer { width: min(100%, 920px); margin: 0 auto; padding: 10px 18px 8px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; border-top: 1px solid rgba(42, 53, 47, .85); background: linear-gradient(var(--base), rgba(11, 16, 14, .98)); } -.composer-input-wrap { min-width: 0; border: 1px solid #3a493f; border-radius: 5px; background: #101813; transition: border-color .15s ease, box-shadow .15s ease; } -.composer-input-wrap:focus-within { border-color: var(--signal); box-shadow: 0 0 0 3px rgba(201, 168, 95, .12); } -.composer textarea { min-height: 48px; max-height: 220px; resize: none; border: 0; background: transparent; } -.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 8px; color: #68776d; font: 9px/1 var(--mono); } +.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; } +.messages { width: min(100%, 900px); min-height: 0; margin: 0 auto; padding: clamp(24px, 5vh, 52px) 22px 20px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; scrollbar-color: var(--line-strong) transparent; } +.message { max-width: min(700px, 92%); padding: 11px 14px 12px; border: 1px solid var(--line); border-radius: 10px; line-height: 1.5; } +.message.user { align-self: flex-end; background: #162231; border-color: #30465b; } +.message.assistant { align-self: flex-start; background: #121d2a; } +.message-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; } +.message-role { color: var(--blue); font: 650 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; } +.message.user .message-role { color: var(--lime); } +.message-time { color: var(--faint); font: 9px/1 var(--mono); } +.message-content { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; } +.message small { display: block; margin-top: 8px; color: var(--faint); font: 9px/1.2 var(--mono); letter-spacing: .03em; text-transform: none; } +.composer { width: min(100%, 900px); margin: 0 auto; padding: 10px 22px 7px; display: grid; grid-template-columns: minmax(0, 1fr) 106px; gap: 9px; border-top: 1px solid var(--line); background: var(--base); } +.composer-input-wrap { min-width: 0; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-soft); transition: border-color .15s ease, box-shadow .15s ease; } +.composer-input-wrap:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.1); } +.composer textarea { min-height: 46px; max-height: 200px; resize: none; border: 0; background: transparent; } +.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 7px; color: var(--faint); font: 9px/1 var(--mono); } .composer-hint output { color: var(--muted); white-space: nowrap; } -.composer button { height: 48px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; padding-inline: 15px; } -.composer button[aria-busy="true"] { color: #4b432d; background: #ad9353; } -.composer button span:last-child { opacity: .65; font: 16px/1 var(--mono); } -.composer button:disabled { cursor: wait; filter: saturate(.3); } -.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; } -.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; } -.media-tools input { max-width: 150px; font: 10px var(--mono); } -.media-tools span { color: var(--signal); } -.conversation-starters { width: min(100%, 920px); margin: -4px auto 0; padding: 0 18px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; } -.conversation-starters button { min-height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #101814; font: 10px/1 var(--mono); } -.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--signal); color: var(--signal); outline: none; } -.ai-data-form { margin-top: 24px; } -.ai-data-form textarea { min-height: 150px; resize: vertical; } -.ai-data-form button { width: 100%; min-height: 42px; margin-top: 12px; border: 0; color: #11150f; background: var(--signal); font-weight: 700; } -.ai-data-error { margin: 10px 0 0; color: var(--bad); font-size: 12px; line-height: 1.4; } -.side-console-link { display: flex; justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--signal) !important; font: 11px/1.3 var(--mono) !important; text-transform: uppercase; letter-spacing: .06em; } -.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; } -.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; } -.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); } -.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; } -.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; } -.attention-list, .history-list { display: grid; gap: 12px; } -.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; } -.attention-card time { color: var(--signal); font: 12px/1 var(--mono); } -.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); } -.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; } -.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } -.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; } -.attention-actions button:first-child { border-color: var(--good); color: var(--good); } -.history-item.user { border-color: #34443c; background: #151e1a; } -.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); } -.empty-state { margin: 24px 0 0; color: var(--muted); } +.composer button { height: 46px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 7px; padding: 0 13px; } +.composer button[aria-busy="true"] { color: #384529; background: #a6ca7a; } +.composer button span:last-child { opacity: .65; font: 15px/1 var(--mono); } +.composer button:disabled { cursor: wait; filter: saturate(.35); } +.media-tools { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 9px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; } +.media-tools label { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; margin: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; } +.media-tools input { max-width: 125px; font: 9px var(--mono); } +.media-tools span { color: var(--yellow); } +.insight-grid { display: grid; gap: 7px; margin-top: 19px; } +.insight-card { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); } +.insight-card strong { display: block; margin-top: 7px; color: var(--ink); font-size: 12px; font-weight: 650; line-height: 1.3; } +.insight-card small { display: block; margin-top: 5px; color: var(--faint); font-size: 10px; line-height: 1.35; } +.ai-info-card { padding-bottom: 0; } +.ai-info-card strong { display: block; margin-top: 8px; font-size: 12px; } +.ai-data-form { margin-top: 18px; } +.ai-data-form textarea { min-height: 126px; } +.ai-data-form button { width: 100%; min-height: 38px; margin-top: 9px; border: 1px solid rgba(197,239,145,.3); border-radius: 7px; color: var(--lime); background: rgba(197,239,145,.1); font-size: 11px; font-weight: 700; } +.ai-data-form button:hover { background: rgba(197,239,145,.16); } +.ai-data-error { margin: 9px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4; } +.side-console-link { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 13px; border-top: 1px solid var(--line); color: var(--blue) !important; font: 10px/1.3 var(--mono) !important; text-decoration: none; letter-spacing: .03em; text-transform: uppercase; } +.attention-surface, .history-surface { width: min(100%, 900px); margin: 0 auto; padding: 28px 22px; align-content: start; gap: 15px; } +.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 13px; } +.surface-heading p { margin: 0; font-size: 27px; font-weight: 680; } +.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 9px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; } +.surface-heading button { height: 31px; padding: 0 10px; border: 1px solid var(--line); background: transparent; font-size: 11px; } +.attention-list, .history-list { display: grid; gap: 9px; } +.attention-card, .history-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 13px; } +.attention-card time { color: var(--yellow); font: 10px/1 var(--mono); } +.attention-card h2 { margin: 8px 0 6px; font-size: 18px; font-weight: 650; } +.attention-card p, .history-item p { margin: 0 0 8px; color: var(--ink); font-size: 12px; line-height: 1.5; } +.attention-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; } +.attention-actions button { height: 30px; padding: 0 10px; border: 1px solid var(--line); background: var(--panel-raised); color: var(--ink); font: 10px/1 var(--mono); } +.attention-actions button:first-child { border-color: rgba(142,215,173,.5); color: var(--good); } +.history-item.user { border-color: #30465b; background: #162231; } +.history-item strong { display: block; margin-bottom: 6px; color: var(--blue); font-size: 12px; } +.empty-state { margin: 20px 0 0; color: var(--muted); font-size: 12px; } .empty-state.error { color: var(--bad); } -@media (max-width: 640px) { +@media (max-width: 780px) { .chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; } + .chat-header { position: sticky; top: 0; z-index: 3; min-height: 58px; padding: 0 12px; } + .chat-header .cognitive-status { flex: 1 1 100px; min-width: 0; max-width: 150px; } + .chat-header .cognitive-console-link, .chat-header > .operator-link, .chat-header .build-meta { display: none; } + .chat-header > #logout { flex: 0 0 34px; } + .chat-layout { height: auto; display: flex; flex-direction: column; } + .chat-main { order: 1; height: 72vh; min-height: 530px; } + .chat-side { min-height: auto; max-height: none; overflow: visible; } + .chat-left { order: 2; border: 0; border-top: 1px solid var(--line); } + .chat-right { order: 3; border: 0; border-top: 1px solid var(--line); } + .messages { padding-inline: 12px; } .composer { grid-template-columns: 1fr; padding-inline: 12px; } .composer button { width: 100%; } .composer-hint { font-size: 8px; } - .conversation-starters { align-items: stretch; } - .conversation-starters span { width: 100%; } - .chat-header { gap: 10px; padding: 0 12px; } - .chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; } - .chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } - .chat-header > .operator-link, .build-meta { display: none; } - .chat-header > #logout { flex: 0 0 38px; } + .media-tools { padding-inline: 12px; } + .side-build { display: none; } +} +@media (max-width: 640px) { .chat-layout { height: auto; display: block; } - .chat-layout { display: flex; flex-direction: column; } - .chat-main { order: 1; } - .chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; } - .chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); } - .chat-left { order: 2; } - .chat-right { order: 3; } - .chat-main { height: 70vh; min-height: 520px; } - .chat-left { padding-bottom: 20px; } - .chat-right { padding-top: 20px; } - .operator-link, .nav-tab { padding: 0 9px; } + .chat-header > .operator-link, .build-meta { display: none; } + .chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } } -@media (min-width: 641px) and (max-width: 1100px) { - .chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; } +@media (min-width: 781px) and (max-width: 1100px) { + .chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; } .chat-side { padding-inline: 14px; } - .side-intro h1, .side-intro h2 { font-size: 30px; } +} + +/* Chat-first pass: navigation and context stay out of the conversation's way. */ +.chat-header { gap: 10px; padding-inline: 18px; } +.header-icon, .header-control, .profile-button { height: 32px; border: 1px solid var(--line); background: transparent; font: 10px/1 var(--mono); } +.header-icon { width: 32px; padding: 0; color: var(--muted); font-size: 15px; } +.header-control, .profile-button { padding: 0 10px; color: var(--muted); } +.header-control { color: var(--ink); } +.header-icon:hover, .header-icon:focus-visible, .header-control:hover, .header-control:focus-visible, .profile-button:hover, .profile-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.header-focus { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.chat-header .cognitive-status { margin-left: auto; max-width: 250px; } +.chat-header .cognitive-console-link { display: none; } +.chat-layout { grid-template-columns: 242px minmax(0, 1fr); } +.chat-layout.nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); } +.chat-left { padding: 24px 16px; transition: opacity .18s ease, padding .18s ease, transform .18s ease; } +.chat-layout.nav-collapsed .chat-left { padding-inline: 0; overflow: hidden; opacity: 0; border-right: 0; } +.side-intro h1 { font-size: 26px; } +.side-section { margin-top: 18px; } +.human-nav { margin-top: 12px; } +.nav-tab { grid-template-columns: 1fr; min-height: 44px; padding-inline: 10px; } +.chat-left .nav-tab b { display: none; } +.new-conversation { margin-top: 16px; } +.chat-main { min-width: 0; } +.messages { width: min(100%, 1040px); padding: 32px 30px 22px; gap: 14px; } +.message { max-width: min(760px, 88%); padding: 14px 17px 15px; border-radius: 12px; } +.message-content { font-size: 15px; line-height: 1.55; } +.message-role { font-size: 10px; } +.message-time { font-size: 10px; } +.message small { font-size: 10px; } +.composer { width: min(100%, 1040px); padding-inline: 30px; } +.composer-input-wrap { min-height: 64px; padding: 0 12px 36px; border-radius: 14px; } +.composer textarea { min-height: 56px; max-height: 180px; padding: 13px 0 8px; font-size: 15px; } +.composer-actions { right: 9px; bottom: 8px; left: 9px; height: 28px; } +.composer-tool { width: 29px; height: 29px; padding: 0; border: 0; color: var(--muted); background: transparent !important; } +.composer-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } +.composer-tool:hover, .composer-tool:focus-visible { border: 0; color: var(--blue); box-shadow: none; } +.composer-hint { font-size: 10px; } +.send-button { width: 33px; height: 33px; flex-basis: 33px; } +.composer-runtime { height: 0; min-height: 0; padding: 0; overflow: visible; } +.attachment-menu { bottom: 42px; left: 8px; } +.context-indicator { display: none; } +.technical-meta { display: none !important; } +.context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); } +.context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; } +.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); } +.password-dialog::backdrop { background: rgba(3,8,14,.68); } +.password-dialog form { padding: 22px; } +.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.dialog-heading h2 { margin: 0; font-size: 20px; } +.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; } +.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); } +.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); } +.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; } +.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; } +.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; } +.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); } +.dialog-actions button:disabled { opacity: .55; cursor: wait; } +#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; } +.context-drawer.drawer-open { transform: translateX(0); } +.drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.drawer-heading h2 { margin-top: 9px; font-size: 26px; } +.drawer-copy { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.drawer-close { width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); background: transparent; font-size: 20px; line-height: 1; } +.drawer-close:hover, .drawer-close:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.insight-grid { margin-top: 20px; } +@media (max-width: 780px) { + .chat-header { padding-inline: 10px; } + .header-focus { display: none; } + .chat-header .cognitive-status { max-width: 145px; } + .profile-button { width: 32px; overflow: hidden; padding: 0; font-size: 0; } + .profile-button::before { content: "●"; font-size: 12px; } + .chat-layout { display: block; } + .chat-main { height: calc(100vh - 58px); min-height: 520px; } + .chat-left { position: fixed; z-index: 10; top: 58px; bottom: 0; left: 0; width: min(290px, 84vw); transform: translateX(-105%); box-shadow: 18px 0 42px rgba(0,0,0,.3); } + .chat-layout.nav-open .chat-left { transform: translateX(0); opacity: 1; overflow-y: auto; } + .chat-layout.nav-collapsed .chat-left { padding-inline: 16px; } + .messages { padding: 22px 12px 16px; } + .message { max-width: 94%; } + .message-content { font-size: 14px; } + .composer { width: 100%; padding-inline: 12px; } + .composer-input-wrap { padding-right: 10px; } + .composer-hint { display: none; } + .attachment-menu { bottom: 42px; left: 5px; } + .context-backdrop { inset: 58px 0 0; } + .context-drawer { top: 58px; } +} + +/* Focused composer: one calm surface, with compact controls kept together. */ +.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; } +.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; } +.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); } +.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; } +.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; } +.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); } +.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; } +.composer-tool:has(.composer-tool-label) { width: auto; min-width: 27px; padding-inline: 7px; gap: 5px; font: 10px/1 var(--mono); } +.composer-tool-label { font-size: 10px; } +.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; } +.context-indicator output { color: var(--muted); } +.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; } +.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; } +.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); } +.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; } +.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; } +.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); } +.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; } +.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; } +.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); } +@media (max-width: 780px) { + .composer { width: 100%; padding-inline: 12px; } + .composer-input-wrap { padding-right: 50px; } + .composer-toolbar { width: 100%; padding-inline: 12px; } + .attachment-menu { left: 12px; } + .composer-hint { display: none; } + .composer-tool:has(.composer-tool-label) { padding-inline: 5px; } + .composer-tool-label { font-size: 9px; } + .context-indicator { font-size: 8px; } } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index a9bb14d..e026f36 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -9,30 +9,67 @@ const sendButton = document.querySelector("#send"); const sendLabel = document.querySelector("#send-label"); const messageCount = document.querySelector("#message-count"); const logoutButton = document.querySelector("#logout"); +const passwordDialog = document.querySelector("#password-dialog"); +const passwordForm = document.querySelector("#password-form"); +const passwordClose = document.querySelector("#password-close"); +const passwordLogout = document.querySelector("#password-logout"); +const passwordStatus = document.querySelector("#password-status"); const activeFocus = document.querySelector("#active-focus"); +const rightFocus = document.querySelector("#right-focus"); +const headerFocus = document.querySelector("#header-focus"); +const navToggle = document.querySelector("#nav-toggle"); +const chatLayout = document.querySelector(".chat-layout"); +const contextToggle = document.querySelector("#context-toggle"); +const contextClose = document.querySelector("#context-close"); +const contextBackdrop = document.querySelector("#context-backdrop"); +const contextDrawer = document.querySelector("#context-drawer"); +const newConversationButton = document.querySelector(".new-conversation"); const cognitiveStatus = document.querySelector("#cognitive-status"); const cognitiveConsoleLink = document.querySelector("#cognitive-console-link"); const todayList = document.querySelector("#today-list"); const refreshToday = document.querySelector("#refresh-today"); const historyList = document.querySelector("#history-list"); const navTabs = Array.from(document.querySelectorAll(".nav-tab")); -const conversationStarters = Array.from(document.querySelectorAll("[data-starter]")); const aiDataForm = document.querySelector("#ai-data-form"); const aiDataInput = document.querySelector("#ai-data"); const aiDataError = document.querySelector("#ai-data-error"); +const attachToggle = document.querySelector("#attach-toggle"); +const attachmentMenu = document.querySelector("#attachment-menu"); +const photoTrigger = document.querySelector("#photo-trigger"); +const micTrigger = document.querySelector("#mic-trigger"); +const sendIcon = document.querySelector("#send-icon"); +const stopIcon = document.querySelector("#stop-icon"); let token = sessionStorage.getItem("domovoy.chat.token") || ""; let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; let localHistory = []; let cognitiveStatusTimer = null; +let activeRequestController = null; +let composingMessage = false; function resizeComposerInput() { if (!messageInput) return; messageInput.style.height = "auto"; - messageInput.style.height = `${Math.min(messageInput.scrollHeight, 220)}px`; + const maxHeight = 178; + messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`; + messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden"; if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`; } +function isChatBusy() { + return sendButton?.getAttribute("aria-busy") === "true"; +} + +function setChatBusy(busy) { + if (!sendButton) return; + sendButton.setAttribute("aria-busy", busy ? "true" : "false"); + sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить"); + sendButton.title = busy ? "Остановить обработку" : "Отправить"; + if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить"; + if (sendIcon) sendIcon.hidden = busy; + if (stopIcon) stopIcon.hidden = !busy; +} + class ChatApiError extends Error { constructor(status, detail = "") { super(detail || `chat api returned ${status}`); @@ -76,10 +113,8 @@ async function loadCognitiveStatus() { const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"}); renderCognitiveStatus(result.State || result.state || "UNKNOWN"); } catch (error) { - if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { - expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); - return; - } + // Cognitive health is an auxiliary probe. Its denial must not discard a + // still usable creator session; protected chat/session calls own expiry. renderCognitiveStatus("UNAVAILABLE"); } } @@ -99,9 +134,6 @@ function stopCognitiveStatusPolling() { function showChat() { loginView.hidden = true; chatView.hidden = false; - if (!messages.children.length) { - appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов."); - } startCognitiveStatusPolling(); messageInput.focus(); resizeComposerInput(); @@ -154,6 +186,41 @@ function updateActiveFocus(value, provenance = "") { if (!activeFocus) return; activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : ""; activeFocus.hidden = !value; + if (rightFocus) rightFocus.textContent = value || "не выбран"; + if (headerFocus) headerFocus.textContent = value || "Новый разговор"; +} + +function setContextOpen(open) { + if (!contextDrawer) return; + contextDrawer.classList.toggle("drawer-open", open); + contextDrawer.setAttribute("aria-hidden", String(!open)); + if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open)); + if (contextBackdrop) contextBackdrop.hidden = !open; +} + +if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true)); +if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false)); +if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false)); +if (navToggle && chatLayout) { + navToggle.addEventListener("click", () => { + const mobile = window.matchMedia("(max-width: 780px)").matches; + const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed"); + navToggle.setAttribute("aria-expanded", String(open)); + navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию"; + }); +} + +if (newConversationButton) { + newConversationButton.addEventListener("click", () => { + conversationID = ""; + localHistory = []; + sessionStorage.removeItem("domovoy.chat.conversation"); + messages.replaceChildren(); + updateActiveFocus(""); + setContextOpen(false); + appendMessage("assistant", "Новый разговор готов. Чем займёмся?"); + messageInput.focus(); + }); } async function api(path, options = {}) { @@ -303,12 +370,11 @@ async function sendChatMessage(text, options = {}) { const clean = text.trim(); if (!clean) return; if (!options.silentUser) appendMessage("user", clean); - sendButton.disabled = true; - sendButton.setAttribute("aria-busy", "true"); - if (sendLabel) sendLabel.textContent = "Домовой обрабатывает"; + activeRequestController = new AbortController(); + setChatBusy(true); messages.setAttribute("aria-busy", "true"); try { - const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})}); + const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal}); conversationID = result.conversation_id; sessionStorage.setItem("domovoy.chat.conversation", conversationID); updateActiveFocus(result.active_focus, result.project_provenance); @@ -318,7 +384,9 @@ async function sendChatMessage(text, options = {}) { activateTab("today-panel"); } } catch (error) { - if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { + if (error.name === "AbortError") { + appendMessage("assistant", "Обработка остановлена."); + } else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); } else if (error instanceof ChatApiError && error.status >= 500) { appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд."); @@ -326,9 +394,8 @@ async function sendChatMessage(text, options = {}) { appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку."); } } finally { - sendButton.disabled = false; - sendButton.removeAttribute("aria-busy"); - if (sendLabel) sendLabel.textContent = "Отправить"; + activeRequestController = null; + setChatBusy(false); messages.removeAttribute("aria-busy"); messageInput.focus(); } @@ -362,6 +429,7 @@ loginForm.addEventListener("submit", async (event) => { chatForm.addEventListener("submit", async (event) => { event.preventDefault(); + if (isChatBusy()) return; const text = messageInput.value.trim(); if (!text) return; messageInput.value = ""; @@ -370,6 +438,13 @@ chatForm.addEventListener("submit", async (event) => { }); messageInput.addEventListener("input", resizeComposerInput); +messageInput.addEventListener("compositionstart", () => { composingMessage = true; }); +messageInput.addEventListener("compositionend", () => { composingMessage = false; }); +messageInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return; + event.preventDefault(); + if (!isChatBusy()) chatForm.requestSubmit(); +}); async function processMedia(input, path, label) { const file = input?.files?.[0]; @@ -399,18 +474,37 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения")); if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения")); -for (const tab of navTabs) { - tab.addEventListener("click", () => activateTab(tab.dataset.tab)); +function toggleAttachmentMenu(open) { + if (!attachmentMenu) return; + const next = typeof open === "boolean" ? open : attachmentMenu.hidden; + attachmentMenu.hidden = !next; + if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next)); } -for (const starter of conversationStarters) { - starter.addEventListener("click", async () => { - const text = starter.dataset.starter || ""; - if (!text || sendButton.disabled) return; - messageInput.value = ""; - await sendChatMessage(text); +if (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu()); +for (const option of document.querySelectorAll("[data-input]")) { + option.addEventListener("click", () => { + const input = document.querySelector(`#${option.dataset.input}`); + toggleAttachmentMenu(false); + if (input) input.click(); }); } +if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click()); +if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click()); +document.addEventListener("click", (event) => { + if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false); +}); +if (sendButton) { + sendButton.addEventListener("click", (event) => { + if (!isChatBusy()) return; + event.preventDefault(); + activeRequestController?.abort(); + }); +} + +for (const tab of navTabs) { + tab.addEventListener("click", () => activateTab(tab.dataset.tab)); +} if (refreshToday) refreshToday.addEventListener("click", loadToday); if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus); @@ -418,7 +512,7 @@ if (aiDataForm) { aiDataForm.addEventListener("submit", async (event) => { event.preventDefault(); const details = aiDataInput.value.trim(); - if (!details || sendButton.disabled) return; + if (!details || isChatBusy()) return; if (containsSecretShapedText(details)) { showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели."); return; @@ -430,8 +524,23 @@ if (aiDataForm) { } if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError()); -logoutButton.addEventListener("click", () => { - expireSession(); +logoutButton.addEventListener("click", () => passwordDialog?.showModal()); +passwordClose?.addEventListener("click", () => passwordDialog?.close()); +passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); }); +passwordForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + passwordStatus.textContent = "Сохраняю…"; + const submit = document.querySelector("#password-submit"); + submit.disabled = true; + try { + await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})}); + passwordForm.reset(); + passwordStatus.textContent = "Пароль изменён без перезапуска."; + } catch (error) { + passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль."; + } finally { + submit.disabled = false; + } }); if (token) { diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index f494e27..db407f2 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -25,56 +25,61 @@ + + +

Сменить пароль

+ + + + +

+
+ +
diff --git a/cmd/reflection-worker/main.go b/cmd/reflection-worker/main.go index fa75273..9901d1b 100644 --- a/cmd/reflection-worker/main.go +++ b/cmd/reflection-worker/main.go @@ -14,6 +14,8 @@ import ( platformsecrets "git.cin.su/m/Assistant/platform/secrets" ) +const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" + func main() { workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity") lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease") @@ -21,7 +23,7 @@ func main() { once := flag.Bool("once", false, "run one cycle and exit") flag.Parse() ctx := context.Background() - password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF") + password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_CREDENTIAL_REF") if err != nil { panic(err) } @@ -71,13 +73,13 @@ func main() { } } -func resolveDatabasePassword(name, refName string) (string, error) { - if _, ok := os.LookupEnv(name); ok { - return "", fmt.Errorf("%s direct environment secret is forbidden", name) +func resolveDatabasePassword(refName string) (string, error) { + if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok { + return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden") } - path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName) - if path == "" || ref == "" { - return "", fmt.Errorf("%s_FILE and %s are required", name, refName) + ref := os.Getenv(refName) + if ref == "" { + return "", fmt.Errorf("%s is required", refName) } root := os.Getenv("SECRET_BACKEND_ROOT") if root == "" { @@ -87,5 +89,5 @@ func resolveDatabasePassword(name, refName string) (string, error) { if err != nil { return "", err } - return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path}) + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath}) } diff --git a/cmd/research-runtime/main.go b/cmd/research-runtime/main.go index 8c6a737..8891369 100644 --- a/cmd/research-runtime/main.go +++ b/cmd/research-runtime/main.go @@ -19,6 +19,8 @@ import ( "git.cin.su/m/Assistant/runtime/health" ) +const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" + func main() { ctx := context.Background() password, err := resolveDatabasePassword() @@ -140,9 +142,9 @@ func resolveDatabasePassword() (string, error) { if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok { return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden") } - path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF") - if path == "" || ref == "" { - return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required") + ref := os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF") + if ref == "" { + return "", fmt.Errorf("EPISTEMICS_POSTGRES_CREDENTIAL_REF is required") } root := os.Getenv("SECRET_BACKEND_ROOT") if root == "" { @@ -152,5 +154,5 @@ func resolveDatabasePassword() (string, error) { if err != nil { return "", err } - return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path}) + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath}) } diff --git a/contracts/identity/session.go b/contracts/identity/session.go index 4256088..68a1416 100644 --- a/contracts/identity/session.go +++ b/contracts/identity/session.go @@ -5,6 +5,14 @@ type CreatorCredentials struct { Password string `json:"password"` } +type CreatorAccount struct { + Username string + ActorID ActorID + PrincipalID PrincipalID + PasswordHash string + Revision int64 +} + type CreatorSession struct { ActorID ActorID PrincipalID PrincipalID diff --git a/database/migrations/052_identity_creator_accounts.sql b/database/migrations/052_identity_creator_accounts.sql new file mode 100644 index 0000000..5994064 --- /dev/null +++ b/database/migrations/052_identity_creator_accounts.sql @@ -0,0 +1,20 @@ +BEGIN; +SET ROLE identity_role; +CREATE TABLE IF NOT EXISTS identity.creator_accounts ( + username TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + password_hash TEXT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1, + password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (username <> ''), + CHECK (actor_id <> ''), + CHECK (principal_id <> ''), + CHECK (password_hash <> ''), + CHECK (revision > 0), + UNIQUE (actor_id, principal_id) +); +CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id); +REVOKE ALL ON identity.creator_accounts FROM PUBLIC; +RESET ROLE; +COMMIT; diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 5712366..3ee5217 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -65,20 +65,15 @@ services: POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD} IDENTITY_POSTGRES_USER: identity_role IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD} - CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token - CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key CREATOR_USERNAME: ${CREATOR_USERNAME} - CREATOR_PASSWORD_FILE: /run/secrets/creator_password WORK_POSTGRES_USER: work_role WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} EPISTEMICS_POSTGRES_USER: epistemics_role EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 - EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password INTERACTION_POSTGRES_USER: interaction_role INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1 - INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password SECRET_BACKEND_ROOT: /run/secrets COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1 COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1} @@ -182,7 +177,6 @@ services: POSTGRES_DATABASE: ${POSTGRES_DB:-assistant} EPISTEMICS_POSTGRES_USER: epistemics_role EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 - EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password SECRET_BACKEND_ROOT: /run/secrets depends_on: { postgres: { condition: service_healthy } } volumes: @@ -202,7 +196,6 @@ services: POSTGRES_DATABASE: ${POSTGRES_DB:-assistant} EPISTEMICS_POSTGRES_USER: epistemics_role EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 - EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password SECRET_BACKEND_ROOT: /run/secrets depends_on: { postgres: { condition: service_healthy } } volumes: diff --git a/modules/identity/domain/password.go b/modules/identity/domain/password.go new file mode 100644 index 0000000..5529b16 --- /dev/null +++ b/modules/identity/domain/password.go @@ -0,0 +1,70 @@ +package domain + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "strconv" + "strings" +) + +const creatorPasswordIterations = 120000 + +var ErrInvalidCreatorPassword = errors.New("invalid creator password") + +func HashCreatorPassword(password string) (string, error) { + if len(password) < 6 || len(password) > 4096 { + return "", ErrInvalidCreatorPassword + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32) + return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil +} + +func VerifyCreatorPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" { + return false + } + iterations, err := strconv.Atoi(parts[1]) + if err != nil || iterations < 100000 || iterations > 1000000 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil || len(salt) < 16 { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil || len(expected) != 32 { + return false + } + actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected)) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte { + key := make([]byte, 0, keyLength) + for block := 1; len(key) < keyLength; block++ { + mac := hmac.New(sha256.New, password) + mac.Write(salt) + mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)}) + u := mac.Sum(nil) + t := append([]byte(nil), u...) + for i := 1; i < iterations; i++ { + mac = hmac.New(sha256.New, password) + mac.Write(u) + u = mac.Sum(nil) + for j := range t { + t[j] ^= u[j] + } + } + key = append(key, t...) + } + return key[:keyLength] +} diff --git a/modules/identity/domain/password_test.go b/modules/identity/domain/password_test.go new file mode 100644 index 0000000..15be8be --- /dev/null +++ b/modules/identity/domain/password_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestCreatorPasswordHashRoundTrip(t *testing.T) { + hash, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + if !VerifyCreatorPassword(hash, "a-long-enough-test-password") { + t.Fatal("hashed password was not accepted") + } + if VerifyCreatorPassword(hash, "wrong-password") { + t.Fatal("wrong password was accepted") + } +} + +func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) { + first, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + second, err := HashCreatorPassword("a-long-enough-test-password") + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("password hashes reused a salt") + } +} + +func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) { + if _, err := HashCreatorPassword("short"); err == nil { + t.Fatal("short password was accepted") + } +} diff --git a/modules/identity/domain/session.go b/modules/identity/domain/session.go index 77ee8f3..3fa7429 100644 --- a/modules/identity/domain/session.go +++ b/modules/identity/domain/session.go @@ -16,10 +16,14 @@ import ( var ErrInvalidCreatorSession = errors.New("invalid creator session") func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) { - if len(secret) < 32 || ttl <= 0 { + return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl) +} + +func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) { + if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 { return "", identity.CreatorSession{}, ErrInvalidCreatorSession } - session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()} + session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()} payload, err := json.Marshal(session) if err != nil { return "", identity.CreatorSession{}, err @@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit return identity.CreatorSession{}, ErrInvalidCreatorSession } var session identity.CreatorSession - if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix { + if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix { return identity.CreatorSession{}, ErrInvalidCreatorSession } return session, nil diff --git a/tests/cognitive_advisory/verify-remote.ps1 b/tests/cognitive_advisory/verify-remote.ps1 index 59da669..536b123 100644 --- a/tests/cognitive_advisory/verify-remote.ps1 +++ b/tests/cognitive_advisory/verify-remote.ps1 @@ -40,14 +40,14 @@ $page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' } $coreEnvironment = Get-ContainerEnvironment $CoreContainer -$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD') +$required = @('CREATOR_USERNAME', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD') foreach ($name in $required) { if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) { throw "Core runtime is missing $name" } } -$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim() +$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '/run/secrets/creator_password'") -join "`n").Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' } $sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress $issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest diff --git a/tests/creator_commit/verify-remote.ps1 b/tests/creator_commit/verify-remote.ps1 index 467197a..578da81 100644 --- a/tests/creator_commit/verify-remote.ps1 +++ b/tests/creator_commit/verify-remote.ps1 @@ -62,7 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int] } } -$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions' +$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$(cat /run/secrets/creator_bootstrap_token); if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions' if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" } $creatorSession = ($sessionResponse | ConvertFrom-Json).Token