57c028f94f
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
166 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|