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 be1a367..dae9bab 100644
--- a/cmd/core-runtime/main.go
+++ b/cmd/core-runtime/main.go
@@ -60,12 +60,11 @@ func main() {
if err != nil {
log.Fatal(err)
}
- creatorPassword, err := readRequiredSecretFile("CREATOR_PASSWORD", creatorPasswordPath)
- 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)
@@ -99,6 +98,9 @@ func main() {
log.Fatal(err)
}
defer identityRepository.Close()
+ 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)
@@ -185,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 {
@@ -1712,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 ") {
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 063b21d..809f2e5 100644
--- a/cmd/creator-web/static/assets/chat.css
+++ b/cmd/creator-web/static/assets/chat.css
@@ -205,6 +205,20 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px;
.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; }
diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js
index 0189475..e026f36 100644
--- a/cmd/creator-web/static/assets/chat.js
+++ b/cmd/creator-web/static/assets/chat.js
@@ -9,6 +9,11 @@ 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");
@@ -519,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 0fca577..db407f2 100644
--- a/cmd/creator-web/static/chat.html
+++ b/cmd/creator-web/static/chat.html
@@ -112,6 +112,17 @@
+