Files
orchestra/internal/authn/http.go
T
kami 57c028f94f Seal the plan as a specification instead of four bullet lists
The plan artifact was Changes{Target,Intent} plus three string lists, every
entry capped at 500 single-line characters. That bound makes a specification
impossible: a phase cannot carry a code block, a paragraph of reasoning, or a
verification command with its own argument list. renderSealed then flattened
what little survived through collapse(), so an implement session received a
summary of a summary.

plan.md replaces it. Markdown, 128 KiB, no per-line cap, sealed through the
existing path under the existing PlanRef. The parser enforces the structure the
brief states: required sections, phases numbered from 1 with no gaps, Files,
Changes and Verification per phase, and at least one automated or manual check,
because a phase nobody can verify can never be established as done. Automated
entries are JSON argv arrays, so a pipe is a literal argument rather than an
operator. Headings inside fenced blocks are content, so a plan may show
markdown without parsing its own example.

Citations resolve at seal time against the accepted research, on the
coordinator, which is the only party holding ResearchRef. A plan resting on a
finding nobody recorded fails on the planner while its session is still alive
to be told.

The plan now renders byte for byte into the implement launch, and a rotated
successor receives the same complete document. That is the property the whole
change exists for. collapse() stays for research findings, which really are
short claims.

DecodeStoredPlan reads pre-markdown refs and renders them into the same type,
labelled, so nothing downstream branches on which era a plan came from. A
legacy plan carries no phases, which is honest: the old artifact never named an
executable unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 11:36:45 +04:00

166 lines
4.5 KiB
Go

package authn
import (
"encoding/json"
"errors"
"net/http"
"orchestra/internal/authz"
"time"
)
const maxLoginBody = 8 << 10
type HTTP struct {
Users *Store
Sessions *authz.Sessions
SecureCookie bool
}
func (h HTTP) cookie(value string, maxAge int) *http.Cookie {
cookie := &http.Cookie{
Name: authz.SessionCookie,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: h.SecureCookie,
}
if maxAge < 0 {
cookie.Expires = time.Unix(1, 0)
}
return cookie
}
func decode(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); err == nil {
return errors.New("multiple JSON values")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (h HTTP) sessionUser(r *http.Request) (User, bool) {
if h.Users == nil || h.Sessions == nil {
return User{}, false
}
cookie, err := r.Cookie(authz.SessionCookie)
if err != nil {
return User{}, false
}
username, ok := h.Sessions.Username(cookie.Value)
if !ok {
return User{}, false
}
user, err := h.Users.User(username)
return user, err == nil
}
func (h HTTP) Session(w http.ResponseWriter, r *http.Request) {
if h.Users == nil || h.Sessions == nil {
http.Error(w, "login unavailable", http.StatusServiceUnavailable)
return
}
switch r.Method {
case http.MethodGet:
user, ok := h.sessionUser(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, user)
case http.MethodPost:
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(w, r, &body); err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
user, err := h.Users.Authenticate(body.Username, body.Password)
if err != nil {
// Credential failures are deliberately indistinguishable. Database
// failures are not exposed either, but they remain a server error.
if errors.Is(err, ErrInvalidCredentials) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
} else {
http.Error(w, "login unavailable", http.StatusInternalServerError)
}
return
}
value, err := h.Sessions.IssueFor(user.Username)
if err != nil {
http.Error(w, "session unavailable", http.StatusInternalServerError)
return
}
http.SetCookie(w, h.cookie(value, int(h.Sessions.Duration().Seconds())))
writeJSON(w, http.StatusOK, user)
case http.MethodDelete:
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
h.Sessions.Revoke(cookie.Value)
}
http.SetCookie(w, h.cookie("", -1))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h HTTP) Account(w http.ResponseWriter, r *http.Request) {
user, ok := h.sessionUser(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, user)
case http.MethodPut:
var body struct {
CurrentPassword string `json:"current_password"`
Username string `json:"username"`
NewPassword string `json:"new_password"`
}
if err := decode(w, r, &body); err != nil || body.CurrentPassword == "" {
http.Error(w, "current password is required", http.StatusBadRequest)
return
}
updated, err := h.Users.Update(user.Username, body.CurrentPassword, body.Username, body.NewPassword)
if err != nil {
switch {
case errors.Is(err, ErrInvalidCredentials):
http.Error(w, "current password is incorrect", http.StatusForbidden)
case errors.Is(err, ErrUsernameExists):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
// Credential changes revoke every browser holding this identity. The
// response carries the updated display name, then the UI signs in again.
h.Sessions.RevokeUser(user.Username)
http.SetCookie(w, h.cookie("", -1))
writeJSON(w, http.StatusOK, updated)
default:
w.Header().Set("Allow", "GET, PUT")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}