merge: pr-5 account JSON into pr-6 base

This commit is contained in:
Reese Norris
2026-07-28 12:05:15 -04:00
5 changed files with 1135 additions and 5 deletions

View File

@@ -45,7 +45,7 @@ The **airport editor** (`/airport-editor`) is a second complexity-gate exception
- Cookie-authenticated API mutations require a CSRF synchronizer token (`csrf_token` form field or `X-CSRF-Token` header matching the `openfsd_csrf` cookie)
- Suspended/inactive ratings cannot open a web session (same as FSD policy)
- **Session cookies are revalidated against the DB on every use** (HTML + dual-accept API): missing or inactive/suspended certificates clear cookies and are rejected; claims (network rating + names) are overlaid from the DB so demotions take effect immediately.
- **Bearer access tokens on dual-accept resource groups** (`/api/v1/user|config|fsdconn|sweatbox|editor/*`) are revalidated the same way (KD-18): demotion, suspension, and soft-delete take effect on the next request. Login/refresh/fsd-jwt remain credential-based and are outside this middleware.
- **Bearer access tokens on dual-accept resource groups** (`/api/v1/user|config|fsdconn|sweatbox|editor|account/*`) are revalidated the same way (KD-18): demotion, suspension, and soft-delete take effect on the next request. Login/refresh/fsd-jwt remain credential-based and are outside this middleware.
### Cookie `Secure` flag (`COOKIE_SECURE`)
| Condition | Secure |
@@ -101,9 +101,7 @@ OpenFSD-API-Version: 2026-07-28
Canonical OpenAPI file: `internal/web/openapi/openapi.v1.yaml` (`//go:embed`). No mirrored copy under `docs/`.
**Outside microversion reject:** `/api/v1/data/*`, `/api/v1/fsd-jwt`, auth login/refresh, discovery, OpenAPI. Resource groups (`/user`, `/users`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope.
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes and sweatbox mutations/`session` are **Stable** (goldens under `testdata/api_v1/<pin>/`). Account JSON is **Provisional**. Design: `docs/design/rest-api-versioning.md`.
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes and sweatbox mutations/`session` are **Stable** (goldens under `testdata/api_v1/<pin>/`). Account JSON (`/api/v1/account/*`) is **Provisional**. Design: `docs/design/rest-api-versioning.md`.
Baseline pin (first supported): **`2026-07-28`**.
@@ -596,6 +594,75 @@ Create a new API access token with a specified expiry (max 90 days).
---
### Account self-service (Provisional)
Operator automation for the same self-service flows as `GET/POST /account` HTML.
**Provisional** until post-release maintainer sign-off — shapes may change without a microversion bump while still Provisional. Dual-accept (Bearer or session cookie + CSRF); self only (OBS+ after revalidation).
#### POST /api/v1/account/password
Change the authenticated actor's password.
**Request Body**:
```json
{
"current_password": "string",
"new_password": "string",
"confirm_password": "string"
}
```
| Rule | Behavior |
|------|----------|
| `current_password` | Required; incorrect → **400** `"incorrect password"` |
| `new_password` | Min **8** chars; must not contain `:` (`validateNewPassword`) |
| `new_password != current_password` | Else **400** |
| `confirm_password` | Required; must equal `new_password` |
**Response (200 OK)**: envelope with `data: null`.
**Auth side effects:** Bearer — none (no session cookies). Cookie dual-accept — may re-issue 24h session + clear CSRF (HTML parity).
**Permissions**: Dual-accept; any active self (OBS+).
---
#### POST /api/v1/account/delete
Soft- or hard-delete the authenticated actor's account (password step-up + CID confirm).
**Request Body**:
```json
{
"current_password": "string",
"confirm_cid": 12345,
"permanent": false
}
```
| Rule | Behavior |
|------|----------|
| Current password | Required + verified (same as password change) |
| `confirm_cid` | Must equal actor CID |
| Soft-delete (default / `permanent` false or omitted) | `network_rating = Inactive`; `data.status = "soft_deleted"` |
| `permanent: true` + `ALLOW_PERMANENT_ACCOUNT_DELETE` | Hard-delete row; `data.status = "hard_deleted"` |
| `permanent: true` + hard-delete **disabled** | **400** `"permanent delete is disabled"`; **no mutation** |
**Intentional JSON divergence from HTML:** HTML falls back to soft-delete and redirects with `permanent=disabled` when permanent was requested but disabled. JSON is **fail-closed** so automation does not silently get a different delete mode — retry with `permanent: false` or enable hard-delete server-side.
**Response (200 OK)**:
```json
{
"version": "v1",
"err": null,
"data": { "status": "soft_deleted" }
}
```
**Auth side effects:** Bearer — none (client drops token). Cookie dual-accept — clears session + CSRF like HTML. After success, drop credentials.
**Permissions**: Dual-accept; any active self (OBS+).
---
### FSD Connection Management
#### POST /api/v1/fsdconn/kickuser

209
internal/web/api_account.go Normal file
View File

@@ -0,0 +1,209 @@
package web
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/pkg/protocol"
)
// setupAccountAPIRoutes mounts Provisional account self-service JSON under
// /api/v1/account (password change + soft/hard delete). Dual-accept OBS+ self.
// Stability: Provisional until post-release maintainer sign-off
// (docs/design/rest-api-versioning.md §C).
func (s *Server) setupAccountAPIRoutes(parent *gin.RouterGroup) {
g := parent.Group("/account")
s.useAPIV1Protected(g)
g.POST("/password", s.handleAPIAccountPassword)
g.POST("/delete", s.handleAPIAccountDelete)
}
// handleAPIAccountPassword POST /api/v1/account/password
//
// Request: { current_password, new_password, confirm_password }
// Validation parity with pages_account.go / validateNewPassword.
// Bearer: password update only (no session cookie side effects).
// Cookie dual-accept: re-issue 24h session + clear CSRF like HTML.
func (s *Server) handleAPIAccountPassword(c *gin.Context) {
claims, ok := requireJwtContext(c)
if !ok {
return
}
var reqBody struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
if !bindJSONOrAbort(c, &reqBody) {
return
}
user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
if err != nil {
// Actor was valid at middleware; treat as unauthorized if gone mid-request.
res := newAPIV1Failure("unauthorized")
writeAPIV1Response(c, http.StatusUnauthorized, &res)
return
}
if reqBody.CurrentPassword == "" {
res := newAPIV1Failure("current password is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if !s.dbRepo.UserRepo.VerifyPasswordHash(reqBody.CurrentPassword, user.Password) {
slog.Debug("api password change rejected bad current", "cid", claims.CID)
res := newAPIV1Failure("incorrect password")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if msg := validateNewPassword(reqBody.NewPassword); msg != "" {
res := newAPIV1Failure(msg)
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if reqBody.ConfirmPassword == "" || reqBody.NewPassword != reqBody.ConfirmPassword {
res := newAPIV1Failure("Passwords do not match")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if reqBody.NewPassword == reqBody.CurrentPassword {
res := newAPIV1Failure("New password must be different from the current password")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
user.Password = reqBody.NewPassword
if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
slog.Error("api account password update failed", "cid", claims.CID, "err", err)
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
// Cookie dual-accept: parity with HTML (KD-7 session re-issue). Bearer: none.
if isSessionAuth(c) {
if err := s.setSessionCookie(c, user, false); err != nil {
slog.Error("api account password session reissue failed", "cid", claims.CID, "err", err)
// Password already updated; report success (mutation cannot roll back cleanly).
} else {
s.clearCSRFCookie(c)
}
}
slog.Info("account password changed",
"cid", claims.CID,
"event", "account_password_changed",
"auth_method", authMethodOf(c),
)
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
}
// handleAPIAccountDelete POST /api/v1/account/delete
//
// Request: { current_password, confirm_cid, permanent? }
// Soft-delete default. permanent:true is fail-closed when hard-delete is
// disabled (intentional JSON divergence from HTML — no silent soft-delete).
// Bearer: no cookie clear. Cookie dual-accept: clear session + CSRF like HTML.
func (s *Server) handleAPIAccountDelete(c *gin.Context) {
claims, ok := requireJwtContext(c)
if !ok {
return
}
var reqBody struct {
CurrentPassword string `json:"current_password"`
ConfirmCID int `json:"confirm_cid"`
Permanent bool `json:"permanent"`
}
if !bindJSONOrAbort(c, &reqBody) {
return
}
user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
if err != nil {
res := newAPIV1Failure("unauthorized")
writeAPIV1Response(c, http.StatusUnauthorized, &res)
return
}
if reqBody.CurrentPassword == "" {
res := newAPIV1Failure("current password is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if !s.dbRepo.UserRepo.VerifyPasswordHash(reqBody.CurrentPassword, user.Password) {
slog.Debug("api account delete rejected bad password", "cid", claims.CID)
res := newAPIV1Failure("incorrect password")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if reqBody.ConfirmCID != user.CID {
res := newAPIV1Failure("confirm_cid must match your CID")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
allowHard := s.cfg != nil && s.cfg.AllowPermanentAccountDelete
if reqBody.Permanent && !allowHard {
// Fail-closed: automation must not silently get soft-delete when it
// requested permanent. Retry with permanent:false for soft-delete.
res := newAPIV1Failure("permanent delete is disabled")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
status := "soft_deleted"
if reqBody.Permanent && allowHard {
if err := s.dbRepo.UserRepo.DeleteUser(user.CID); err != nil {
slog.Error("api account hard-delete failed", "cid", claims.CID, "err", err)
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
status = "hard_deleted"
slog.Info("account deleted",
"cid", claims.CID,
"event", "account_deleted",
"mode", "hard",
"auth_method", authMethodOf(c),
)
} else {
// Soft-delete: Inactive rating; empty Password keeps existing hash (UpdateUser).
user.NetworkRating = int(protocol.NetworkRatingInactive)
user.Password = ""
if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
slog.Error("api account soft-delete failed", "cid", claims.CID, "err", err)
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
slog.Info("account deleted",
"cid", claims.CID,
"event", "account_deleted",
"mode", "soft",
"auth_method", authMethodOf(c),
)
}
if isSessionAuth(c) {
s.clearSessionCookie(c)
s.clearCSRFCookie(c)
}
res := newAPIV1Success(map[string]string{"status": status})
writeAPIV1Response(c, http.StatusOK, &res)
}
func isSessionAuth(c *gin.Context) bool {
m, _ := c.Get(authMethodContextKey)
return m == authMethodSession
}
func authMethodOf(c *gin.Context) string {
m, _ := c.Get(authMethodContextKey)
if s, ok := m.(string); ok && s != "" {
return s
}
return "unknown"
}

View File

@@ -0,0 +1,580 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/renorris/openfsd/pkg/protocol"
)
func apiAccountJSON(t *testing.T, ts *testServer, method, path string, body any, bearer string, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var rdr *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
if len(cookies) > 0 {
req.Header.Set("Cookie", cookieHeader(cookies))
if csrf := csrfFromCookies(cookies); csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
}
}
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
return w
}
func loginAccessToken(t *testing.T, ts *testServer, cid int, password string) string {
t.Helper()
body := map[string]any{"cid": cid, "password": password}
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status %d body %s", w.Code, w.Body.String())
}
var res APIV1Response
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(res.Data)
var tokens struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(data, &tokens); err != nil {
t.Fatal(err)
}
if tokens.AccessToken == "" {
t.Fatal("empty access_token")
}
return tokens.AccessToken
}
func decodeAccountEnvelope(t *testing.T, w *httptest.ResponseRecorder) APIV1Response {
t.Helper()
var res APIV1Response
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decode: %v body=%s", err, w.Body.String())
}
return res
}
func errString(res APIV1Response) string {
if res.Err == nil {
return ""
}
return *res.Err
}
// setCookieIsClear reports whether a Set-Cookie header line clears the named cookie.
func setCookieIsClear(sc, name string) bool {
if !strings.HasPrefix(sc, name+"=") {
return false
}
return strings.Contains(sc, "Max-Age=0") ||
strings.Contains(sc, "Max-Age=-1") ||
strings.Contains(sc, name+"=;") ||
strings.HasPrefix(sc, name+"=;")
}
// setCookieIsLiveSession reports a non-clear session Set-Cookie (re-issue).
func setCookieIsLiveSession(sc string) bool {
if !strings.HasPrefix(sc, sessionCookieName+"=") {
return false
}
return !setCookieIsClear(sc, sessionCookieName)
}
func TestAPIAccountPasswordBearerSuccess(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "oldpassword")
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/password", map[string]any{
"current_password": "oldpassword",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
}, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if res.Err != nil {
t.Fatalf("err=%v", *res.Err)
}
// Design §C: 200 envelope data: null.
if res.Data != nil {
t.Fatalf("data=%v want null", res.Data)
}
if !strings.Contains(w.Body.String(), `"data":null`) {
t.Fatalf("body missing \"data\":null: %s", w.Body.String())
}
// Bearer: no session cookie side effects.
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if strings.HasPrefix(sc, sessionCookieName+"=") {
t.Fatalf("Bearer password change must not Set-Cookie session: %s", sc)
}
if strings.HasPrefix(sc, csrfCookieName+"=") {
t.Fatalf("Bearer password change must not touch CSRF cookie: %s", sc)
}
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if !ts.dbRepo.UserRepo.VerifyPasswordHash("newpassword1", u.Password) {
t.Fatal("password not updated")
}
if ts.dbRepo.UserRepo.VerifyPasswordHash("oldpassword", u.Password) {
t.Fatal("old password still valid")
}
}
func TestAPIAccountPasswordCookieSessionReissue(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "oldpassword")
// Ensure CSRF is present (from HTML GET).
wGET, cookies := authedGET(t, ts, "/account", cookies)
if wGET.Code != http.StatusOK {
t.Fatalf("GET /account %d", wGET.Code)
}
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/password", map[string]any{
"current_password": "oldpassword",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
}, "", cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
// Password must actually change (not only cookie side effects).
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if !ts.dbRepo.UserRepo.VerifyPasswordHash("newpassword1", u.Password) {
t.Fatal("password not updated on cookie dual-accept path")
}
// Session re-issued for cookie dual-accept (rememberMe=false → Max-Age=86400).
foundSession := false
clearedCSRF := false
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if setCookieIsLiveSession(sc) {
foundSession = true
// sessionDefaultTTL = 24h when rememberMe=false.
if !strings.Contains(sc, "Max-Age=86400") {
t.Fatalf("session re-issue want Max-Age=86400 (24h), got %s", sc)
}
}
if setCookieIsClear(sc, csrfCookieName) {
clearedCSRF = true
}
}
if !foundSession {
t.Fatal("expected session Set-Cookie re-issue on cookie password change")
}
if !clearedCSRF {
t.Fatal("expected CSRF cookie clear on cookie password change (HTML parity)")
}
}
func TestAPIAccountPasswordValidation(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "oldpassword")
cases := []struct {
name string
body map[string]any
want string
}{
{
name: "empty current",
body: map[string]any{
"current_password": "",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
},
want: "current password is required",
},
{
name: "wrong current",
body: map[string]any{
"current_password": "nope-nope",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
},
want: "incorrect password",
},
{
name: "short new",
body: map[string]any{
"current_password": "oldpassword",
"new_password": "short",
"confirm_password": "short",
},
want: "at least 8 characters",
},
{
name: "colon new",
body: map[string]any{
"current_password": "oldpassword",
"new_password": "bad:colon1",
"confirm_password": "bad:colon1",
},
want: "colon",
},
{
name: "mismatch confirm",
body: map[string]any{
"current_password": "oldpassword",
"new_password": "newpassword1",
"confirm_password": "newpassword2",
},
want: "Passwords do not match",
},
{
name: "missing confirm",
body: map[string]any{
"current_password": "oldpassword",
"new_password": "newpassword1",
},
want: "Passwords do not match",
},
{
name: "same as current",
body: map[string]any{
"current_password": "oldpassword",
"new_password": "oldpassword",
"confirm_password": "oldpassword",
},
want: "different from the current password",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/password", tc.body, token, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d want 400 body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if !strings.Contains(errString(res), tc.want) {
t.Fatalf("err=%q want substring %q", errString(res), tc.want)
}
// Password unchanged on every validation failure.
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if !ts.dbRepo.UserRepo.VerifyPasswordHash("oldpassword", u.Password) {
t.Fatal("password must not change on validation failure")
}
})
}
}
func TestAPIAccountPasswordUnauthenticated(t *testing.T) {
ts := newTestServer(t)
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/password", map[string]any{
"current_password": "x",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
}, "", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status %d want 401", w.Code)
}
}
func TestAPIAccountPasswordCookieRequiresCSRF(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "oldpassword")
_, cookies = authedGET(t, ts, "/account", cookies)
b, _ := json.Marshal(map[string]any{
"current_password": "oldpassword",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/account/password", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", cookieHeader(cookies))
// Intentionally omit X-CSRF-Token
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status %d want 403 body %s", w.Code, w.Body.String())
}
}
func TestAPIAccountDeleteSoftBearer(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "delete-me1", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "delete-me1")
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "delete-me1",
"confirm_cid": user.CID,
}, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if res.Err != nil {
t.Fatalf("err=%v", *res.Err)
}
data, _ := json.Marshal(res.Data)
if !strings.Contains(string(data), `"soft_deleted"`) {
t.Fatalf("data=%s want soft_deleted", data)
}
// Bearer: no cookie clear side effects required (none set).
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if strings.HasPrefix(sc, sessionCookieName+"=") {
t.Fatalf("Bearer delete must not Set-Cookie session: %s", sc)
}
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingInactive) {
t.Fatalf("rating=%d want Inactive", u.NetworkRating)
}
// Subsequent Bearer use is rejected (revalidation).
w2 := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/password", map[string]any{
"current_password": "delete-me1",
"new_password": "newpassword1",
"confirm_password": "newpassword1",
}, token, nil)
if w2.Code != http.StatusUnauthorized {
t.Fatalf("post-soft-delete bearer status %d want 401", w2.Code)
}
}
func TestAPIAccountDeleteHardWhenEnabled(t *testing.T) {
ts := newTestServer(t)
ts.cfg.AllowPermanentAccountDelete = true
user := createTestUser(t, ts, "hard-del1", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "hard-del1")
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "hard-del1",
"confirm_cid": user.CID,
"permanent": true,
}, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
data, _ := json.Marshal(res.Data)
if !strings.Contains(string(data), `"hard_deleted"`) {
t.Fatalf("data=%s want hard_deleted", data)
}
_, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err == nil {
t.Fatal("expected row gone after hard delete")
}
}
func TestAPIAccountDeletePermanentFailClosed(t *testing.T) {
ts := newTestServer(t)
// AllowPermanentAccountDelete remains false (default).
user := createTestUser(t, ts, "soft-only1", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "soft-only1")
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "soft-only1",
"confirm_cid": user.CID,
"permanent": true,
}, token, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d want 400 body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if !strings.Contains(errString(res), "permanent delete is disabled") {
t.Fatalf("err=%q", errString(res))
}
// No mutation — intentional JSON divergence from HTML soft-fallback.
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingObserver) {
t.Fatalf("rating=%d want OBS (no silent soft-delete)", u.NetworkRating)
}
}
// Cookie dual-accept fail-closed: must not clear session (HTML permanent-disabled
// soft-deletes and logs out; JSON must keep the session and leave rating OBS).
func TestAPIAccountDeletePermanentFailClosedCookie(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "soft-only2", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "soft-only2")
_, cookies = authedGET(t, ts, "/account", cookies)
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "soft-only2",
"confirm_cid": user.CID,
"permanent": true,
}, "", cookies)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d want 400 body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if !strings.Contains(errString(res), "permanent delete is disabled") {
t.Fatalf("err=%q", errString(res))
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingObserver) {
t.Fatalf("rating=%d want OBS (no silent soft-delete)", u.NetworkRating)
}
// Fail-closed must not log the user out.
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if setCookieIsClear(sc, sessionCookieName) {
t.Fatalf("fail-closed must not clear session: %s", sc)
}
if setCookieIsClear(sc, csrfCookieName) {
t.Fatalf("fail-closed must not clear CSRF: %s", sc)
}
}
// Session still usable for a follow-up HTML GET.
w2, _ := authedGET(t, ts, "/account", cookies)
if w2.Code != http.StatusOK {
t.Fatalf("session should remain valid after fail-closed, GET /account status %d", w2.Code)
}
}
func TestAPIAccountDeleteValidation(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "keep-me1x", int(protocol.NetworkRatingObserver))
token := loginAccessToken(t, ts, user.CID, "keep-me1x")
cases := []struct {
name string
body map[string]any
want string
}{
{
name: "empty password",
body: map[string]any{
"current_password": "",
"confirm_cid": user.CID,
},
want: "current password is required",
},
{
name: "wrong password",
body: map[string]any{
"current_password": "wrong-pass",
"confirm_cid": user.CID,
},
want: "incorrect password",
},
{
name: "cid mismatch",
body: map[string]any{
"current_password": "keep-me1x",
"confirm_cid": 999999,
},
want: "confirm_cid",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", tc.body, token, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d want 400 body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
if !strings.Contains(errString(res), tc.want) {
t.Fatalf("err=%q want %q", errString(res), tc.want)
}
u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
if err != nil {
t.Fatal(err)
}
if u.NetworkRating != int(protocol.NetworkRatingObserver) {
t.Fatalf("must remain active, rating=%d", u.NetworkRating)
}
})
}
}
func TestAPIAccountDeleteCookieClearsSession(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "delete-me2", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, user.CID, "delete-me2")
_, cookies = authedGET(t, ts, "/account", cookies)
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "delete-me2",
"confirm_cid": user.CID,
"permanent": false,
}, "", cookies)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
clearedSession := false
clearedCSRF := false
for _, sc := range w.Result().Header.Values("Set-Cookie") {
if setCookieIsClear(sc, sessionCookieName) {
clearedSession = true
}
if setCookieIsClear(sc, csrfCookieName) {
clearedCSRF = true
}
}
if !clearedSession {
t.Fatal("expected session cookie clear on cookie dual-accept delete")
}
if !clearedCSRF {
t.Fatal("expected CSRF cookie clear on cookie dual-accept delete (HTML parity)")
}
}
func TestAPIAccountDeleteOmitsPermanentIsSoft(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "delete-me3", int(protocol.NetworkRatingSupervisor))
token := loginAccessToken(t, ts, user.CID, "delete-me3")
w := apiAccountJSON(t, ts, http.MethodPost, "/api/v1/account/delete", map[string]any{
"current_password": "delete-me3",
"confirm_cid": user.CID,
}, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
res := decodeAccountEnvelope(t, w)
data, _ := json.Marshal(res.Data)
if !strings.Contains(string(data), `"soft_deleted"`) {
t.Fatalf("data=%s", data)
}
}

View File

@@ -25,6 +25,11 @@ servers:
description: Relative to the openfsd web listener
tags:
- name: Account
description: |
Self-service password change and account delete (Provisional).
Any OBS+ dual-accept actor; self only. Shape may change without a
microversion bump until promoted to Stable (post-release sign-off).
- name: Discovery
description: Version-agnostic discovery and machine-readable schema
- name: Auth
@@ -881,6 +886,274 @@ paths:
"502":
description: FSD unreachable / unexpected
/account/password:
post:
tags: [Account]
summary: Change own password (self-service)
description: |
Change the authenticated actor's password. Validation parity with the
HTML `/account/password` form (`validateNewPassword`, confirm match,
new ≠ current).
**Stability: Provisional** — request/response shape may change without a
microversion bump until maintainer sign-off promotes this route to Stable.
**Auth side effects:** Bearer success does **not** set or clear session
cookies. Cookie dual-accept may re-issue a 24h session cookie and clear
CSRF (parity with HTML).
operationId: postAccountPassword
x-openfsd-stability: provisional
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AccountPasswordRequest"
responses:
"200":
description: Password updated (`data` is null)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"400":
description: |
Validation failure (empty current, incorrect password, weak new
password, confirm mismatch, new equals current) or bad API version.
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"401":
description: Missing/invalid credentials
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/account/delete:
post:
tags: [Account]
summary: Soft- or hard-delete own account
description: |
Delete the authenticated actor's account after password step-up and
`confirm_cid` match.
- Default / `permanent: false`: soft-delete (`network_rating = Inactive`).
- `permanent: true` and `ALLOW_PERMANENT_ACCOUNT_DELETE=true`: hard-delete row.
- **`permanent: true` when hard-delete is disabled → 400**
`"permanent delete is disabled"` and **no mutation**.
**Intentional JSON divergence from HTML:** the HTML form falls back to
soft-delete and redirects with `permanent=disabled` when permanent was
requested but disabled. Automation must not silently receive a different
delete mode than requested — retry with `permanent: false` for soft-delete,
or enable hard-delete server-side.
**Stability: Provisional.** Bearer success does not clear cookies (client
drops the token). Cookie dual-accept clears session + CSRF like HTML.
After success, clients must drop credentials.
operationId: postAccountDelete
x-openfsd-stability: provisional
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AccountDeleteRequest"
responses:
"200":
description: Account deleted
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1EnvelopeAccountDelete"
"400":
description: |
Validation failure, permanent delete disabled (fail-closed), or bad
API version. No account mutation when permanent is requested but
disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"401":
description: Missing/invalid credentials
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
components:
responses:
BadAPIVersion:
description: |
Invalid or unsupported `OpenFSD-API-Version` on a dual-accept resource group
(HTTP 400 envelope). Also used when the request body is invalid JSON on
some routes — check `err` text.
Recovery: call version-agnostic `GET /api/v1/versions` (never rejects on pin)
and upgrade the client pin.
headers:
OpenFSD-API-Version:
schema: { type: string }
description: Echo of current max (or registry effective)
OpenFSD-API-Min-Version:
schema: { type: string }
OpenFSD-API-Max-Version:
schema: { type: string }
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
examples:
invalid:
summary: Malformed pin
value:
version: v1
err: invalid OpenFSD-API-Version
data: null
unsupported:
summary: Unknown or sunset pin
value:
version: v1
err: 'unsupported OpenFSD-API-Version "2020-01-01"; min=2026-07-28 max=2026-07-28'
data: null
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Access JWT (API token or short-lived access). No CSRF required.
cookieAuth:
type: apiKey
in: cookie
name: openfsd_session
description: First-party session cookie; mutations require CSRF synchronizer.
parameters:
OpenFSDAPIVersion:
name: OpenFSD-API-Version
in: header
required: false
description: |
Date microversion pin (`YYYY-MM-DD` or `1.YYYYMMDD` or `latest`).
Production clients MUST send a supported pin. Omitted → max + Defaulted header.
schema:
type: string
example: "2026-07-28"
schemas:
APIV1Envelope:
type: object
required: [version, err, data]
properties:
version:
type: string
enum: [v1]
description: URL major only; microversion is header-only
err:
type: string
nullable: true
data:
nullable: true
DiscoveryData:
type: object
required: [major, min_version, max_version, versions, header, default, openapi]
properties:
major: { type: string, example: v1 }
min_version: { type: string, example: "2026-07-28" }
max_version: { type: string, example: "2026-07-28" }
versions:
type: array
items: { type: string }
header: { type: string, example: OpenFSD-API-Version }
default: { type: string, example: max }
openapi: { type: string, example: /api/v1/openapi.json }
APIV1EnvelopeDiscovery:
allOf:
- $ref: "#/components/schemas/APIV1Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/DiscoveryData"
CreateTokenData:
type: object
required: [token, recommended_api_version, api_version_min, api_version_max]
properties:
token: { type: string }
recommended_api_version: { type: string, example: "2026-07-28" }
api_version_min: { type: string, example: "2026-07-28" }
api_version_max: { type: string, example: "2026-07-28" }
APIV1EnvelopeCreateToken:
allOf:
- $ref: "#/components/schemas/APIV1Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/CreateTokenData"
AccountPasswordRequest:
type: object
required: [current_password, new_password, confirm_password]
properties:
current_password:
type: string
description: Existing password (required; incorrect → 400 "incorrect password")
new_password:
type: string
description: Min 8 characters; must not contain ":"; must differ from current
confirm_password:
type: string
description: Must equal new_password (required for JSON parity with HTML)
AccountDeleteRequest:
type: object
required: [current_password, confirm_cid]
properties:
current_password:
type: string
confirm_cid:
type: integer
description: Must equal the actor CID
minimum: 1
permanent:
type: boolean
default: false
description: |
When true and ALLOW_PERMANENT_ACCOUNT_DELETE is enabled, hard-delete.
When true and hard-delete is disabled, 400 fail-closed (no mutation).
AccountDeleteData:
type: object
required: [status]
properties:
status:
type: string
enum: [soft_deleted, hard_deleted]
APIV1EnvelopeAccountDelete:
allOf:
- $ref: "#/components/schemas/APIV1Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/AccountDeleteData"
components:
responses:
BadAPIVersion:

View File

@@ -44,12 +44,13 @@ func (s *Server) setupRoutes() (*gin.Engine, error) {
s.setupAuthRoutes(apiV1Group) // login/refresh: soft version headers only
s.setupDataRoutes(apiV1Group) // never version-reject
// Dual-accept JSON resource groups (jwt + csrf + apiVersion).
// Dual-accept JSON resource groups (jwt + csrf + apiVersion + bearer revalidate).
s.setupUserRoutes(apiV1Group)
s.setupConfigRoutes(apiV1Group)
s.setupFsdConnRoutes(apiV1Group)
s.setupSweatboxAPIRoutes(apiV1Group)
s.setupEditorAPIRoutes(apiV1Group)
s.setupAccountAPIRoutes(apiV1Group)
// Frontend groups
s.setupFrontendRoutes(e.Group(""))