Loading docs/openapi.yaml +83 −9 Changes for docs/openapi.yaml: 83 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -1307,14 +1307,75 @@ paths: type: array items: type: object required: [request_id, username, email, created_at] required: [request_id, username, email, client_ip, created_at] properties: request_id: { type: string } username: { type: string } email: { type: string } client_ip: type: string description: Where start was called from; empty on requests from before this was recorded. created_at: { type: string, format: date-time } '401': $ref: '#/components/responses/Unauthorized' /api/v1/internal/op-login/{id}: get: tags: [account-internal] operationId: opLoginShow summary: Show an in-game admin whose op.console login a request is (spec §B). description: > Internal-only. velocity's /felis web op approve <code> reads this and shows the admin the account, its address, and when and from where the sign-in was started, then asks them to confirm by typing the account name (see approve). The approver's online-mode UUID gets the same check as approve (a linked admin or owner, else 403 not_admin), since the command runs for any player and a staff address must not be readable by one. A request that is unknown, expired, approved or consumed is 404. x-felis-face: [internal] x-felis-tier: service x-felis-callers: [velocity] security: [{ serviceToken: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: approver_uuid, in: query, required: true, schema: { type: string, format: uuid } } responses: '200': description: The pending request and where it was started. content: application/json: schema: type: object required: [request_id, username, email, client_ip, user_agent, created_at, expires_at] properties: request_id: { type: string } username: { type: string } email: { type: string } client_ip: type: string description: Where start was called from; empty on requests from before this was recorded. user_agent: type: string description: The browser's User-Agent at start, up to 256 bytes; may be empty. created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } '400': description: approver_uuid is required (bad_request). content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': description: The approver is not a linked administrator (not_admin). content: application/json: schema: { $ref: '#/components/schemas/Error' } '404': description: No pending operator login with that id (op_login_not_found). content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/op-login/{id}/approve: post: Loading @@ -1323,11 +1384,14 @@ paths: summary: Record an in-game admin's vouch for a pending op.console login (spec §B). description: > Internal-only second factor: velocity submits the online-mode UUID of the in-game admin running /felis web op approve. The API resolves it to a linked role=admin account (else 403 not_admin) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. in-game admin running /felis web op approve <code> <username>, and the account name they typed after seeing the request (GET /api/v1/internal/op-login/{id}). The API resolves the UUID to a linked admin or owner account (else 403 not_admin), requires the typed name to match the request's account ignoring case (else 409 op_login_mismatch, audited, request left pending) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. x-felis-face: [internal] x-felis-tier: service x-felis-callers: [velocity] Loading @@ -1340,9 +1404,12 @@ paths: application/json: schema: type: object required: [approver_uuid] required: [approver_uuid, username] properties: approver_uuid: { type: string, format: uuid } username: type: string description: The account name the admin typed to confirm whose sign-in this is. responses: '200': description: The vouch was recorded; the request is now approved. Loading @@ -1350,11 +1417,13 @@ paths: application/json: schema: type: object required: [approved] required: [approved, username, email] properties: approved: { type: boolean, const: true } username: { type: string } email: { type: string } '400': description: approver_uuid is required (bad_request). description: approver_uuid and username are required (bad_request). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading @@ -1370,6 +1439,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } '409': description: The typed name is not the request's account (op_login_mismatch). content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/servers/{name}/backup: post: Loading internal/api/api.go +1 −0 Changes for internal/api/api.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -434,6 +434,7 @@ func (a *API) internalAPIRoutes() []apiRoute { // approve action (service-token auth, no Principal); the public face carries // the start/status/finish the staff member's browser drives. {Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending}, {Method: "GET", Pattern: "/api/v1/internal/op-login/{id}", Callers: proxy, h: a.handleOpLoginShow}, {Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", Callers: proxy, h: a.handleOpLoginApprove}, // Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to Loading internal/api/api_test.go +16 −13 Changes for internal/api/api_test.go: 16 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -263,6 +263,8 @@ type fakeOpLogin struct { consumed bool expiresAt time.Time createdAt time.Time clientIP string userAgent string } // fakeSetupToken mirrors a setup_tokens row (spec §B setup): a one-time Loading Loading @@ -1606,25 +1608,27 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code // CreateOpLoginRequest records a fresh pending op.console login attempt. status is // born 'pending'; createdAt orders the pending list (the PG ORDER BY created_at). func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, id, userID, email string, expiresAt time.Time) error { f.opLogins[id] = &fakeOpLogin{ id: id, userID: userID, email: email, status: "pending", expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, req NewOpLoginRequest) error { f.opLogins[req.ID] = &fakeOpLogin{ id: req.ID, userID: req.UserID, email: req.Email, status: "pending", expiresAt: req.ExpiresAt, createdAt: req.ExpiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation clientIP: req.ClientIP, userAgent: req.UserAgent, } return nil } // OpLoginRequestByID loads a request by handle, projecting the fake row into the // OpLoginRequest the status/finish paths read (Status, Consumed, ExpiresAt). Status // is the (approved_at, denied_at) projection the handler gates on. // OpLoginRequest the handlers read, with the username joined like the PG query. // Status is the (approved_at, denied_at) projection the handler gates on. func (f *fakeRepo) OpLoginRequestByID(_ context.Context, id string) (*OpLoginRequest, error) { r, ok := f.opLogins[id] if !ok { return nil, ErrNotFound } return &OpLoginRequest{ ID: r.id, UserID: r.userID, Email: r.email, ExpiresAt: r.expiresAt, Status: r.status, Consumed: r.consumed, ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email, ExpiresAt: r.expiresAt, CreatedAt: r.createdAt, Status: r.status, Consumed: r.consumed, ClientIP: r.clientIP, UserAgent: r.userAgent, }, nil } Loading @@ -1644,8 +1648,7 @@ func (f *fakeRepo) ConsumeOpLoginRequest(_ context.Context, id string, now time. // ListPendingOpLogins returns the live (pending, unconsumed, unexpired) requests // oldest-first, mirroring the PG WHERE consumed_at IS NULL AND approved_at IS NULL // AND expires_at > now ORDER BY created_at. Username is joined from the staff map // (the in-game admin needs to name who is waiting), exactly as the repo.go contract // documents — ListPendingOpLogins is the ONLY path that populates Username. // (the in-game admin needs to name who is waiting). func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLoginRequest, error) { var out []OpLoginRequest for _, r := range f.opLogins { Loading @@ -1655,6 +1658,7 @@ func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLo out = append(out, OpLoginRequest{ ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email, ExpiresAt: r.expiresAt, Status: "pending", CreatedAt: r.createdAt, ClientIP: r.clientIP, UserAgent: r.userAgent, }) } sort.Slice(out, func(i, j int) bool { Loading Loading @@ -1690,9 +1694,8 @@ func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now ti return tok.UserID, nil } // usernameFor joins a userID to its staff username (the ListPendingOpLogins // projection the in-game admin needs to name who is waiting). "" when the user is // gone — mirroring a missing JOIN row. // usernameFor joins a userID to its staff username (the op-login projection the // in-game admin needs to name who is waiting). "" when the user is gone. func (f *fakeRepo) usernameFor(userID string) string { for _, u := range f.staff { if u.ID == userID { Loading internal/api/handlers_op_login.go +127 −36 Changes for internal/api/handlers_op_login.go: 127 added lines, 36 removed lines. Original line number Diff line number Diff line Loading @@ -11,14 +11,15 @@ import ( // sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind // code), a staff web session is never minted from a single factor. The flow is a // three-call state machine over op_login_requests (migration 0016), all Public // pre-session routes (the caller has no principal yet), plus two internal-face routes // for the in-game side (approve is driven by velocity's /felis command; pending has // no consumer yet — see handleOpLoginPending): // pre-session routes (the caller has no principal yet), plus internal-face routes // for the in-game side (show and approve are driven by velocity's /felis command; // pending has no consumer yet — see handleOpLoginPending): // // POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP // GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves // POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session // GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch // GET /api/v1/internal/op-login/{id} (internal) — who a request is for, shown to the admin // POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches // // The two factors: Loading @@ -30,6 +31,9 @@ import ( // request via velocity's /felis command (internal approve). The API's own user // table is the sole authority: only a UUID linked to a staff account may // approve (velocity's command runs for any player and relies on this check). // The admin first sees whose request it is (account, address, where it was // started) and approves by typing that account's name, so a code relayed by a // stranger ("please approve abc123") cannot be vouched for blind. // // finish mints the session only when BOTH have landed. Neither factor alone — a mailed // code without an approval, or an approval without the code — yields a session. Loading Loading @@ -173,7 +177,14 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil { origin := "" if addr := a.clientIP(r); addr.IsValid() { origin = addr.String() } if err := a.Repo.CreateOpLoginRequest(r.Context(), NewOpLoginRequest{ ID: id, UserID: u.ID, Email: u.Email, ExpiresAt: expiresAt, ClientIP: origin, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent), }); err != nil { writeError(w, r, err) return } Loading Loading @@ -375,26 +386,106 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) { "request_id": req.ID, "username": req.Username, "email": req.Email, "client_ip": req.ClientIP, "created_at": req.CreatedAt.UTC(), }) } writeJSON(w, http.StatusOK, map[string]any{"pending": out}) } // opLoginApprover resolves the in-game player running /felis web op approve to a // linked staff account (admin, or the owner superset). An unlinked UUID or a // non-staff player may never see or vouch for an op.console login; all refusals // share one 403 so a caller cannot tell "not linked" from "linked but not staff". func (a *API) opLoginApprover(r *http.Request, mcUUID string) (*StaffUser, error) { notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login") approverID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) switch { case errors.Is(err, ErrNotFound): return nil, notAdmin case err != nil: return nil, err } approver, err := a.Repo.UserByID(r.Context(), approverID) switch { case errors.Is(err, ErrNotFound): return nil, notAdmin case err != nil: return nil, err } if !staffRole(approver.Role) { return nil, notAdmin } return approver, nil } // pendingOpLogin loads a request an admin may still vouch for: pending, unconsumed // and unexpired. Anything else is the same 404 the approve race returns. func (a *API) pendingOpLogin(r *http.Request, id string) (*OpLoginRequest, error) { notFound := newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id") req, err := a.Repo.OpLoginRequestByID(r.Context(), id) switch { case errors.Is(err, ErrNotFound): return nil, notFound case err != nil: return nil, err } if req.Status != "pending" || req.Consumed || !req.ExpiresAt.After(a.now()) { return nil, notFound } return req, nil } // handleOpLoginShow tells the in-game admin who a pending request is for before // they vouch (internal face): the account, its address, when and from where the // sign-in was started. velocity's /felis web op approve <code> renders this and // asks the admin to confirm by name. The approver UUID rides in the query and gets // the same staff check as approve, since velocity's command runs for any player and // a staff address must not be readable by one. func (a *API) handleOpLoginShow(w http.ResponseWriter, r *http.Request) { approverUUID := strings.TrimSpace(r.URL.Query().Get("approver_uuid")) if approverUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required")) return } if _, err := a.opLoginApprover(r, approverUUID); err != nil { writeError(w, r, err) return } req, err := a.pendingOpLogin(r, r.PathValue("id")) if err != nil { writeError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{ "request_id": req.ID, "username": req.Username, "email": req.Email, "client_ip": req.ClientIP, "user_agent": req.UserAgent, "created_at": req.CreatedAt.UTC(), "expires_at": req.ExpiresAt.UTC(), }) } // opLoginApproveRequest is the internal approve body: the online-mode UUID of the // in-game admin running /felis web op approve. The API resolves it to a linked account // and refuses unless that account is staff (admin or owner) — this check against the API's // authoritative user table is the only gate; velocity's command itself is unprivileged. // in-game admin running /felis web op approve, and the account name they typed to // confirm whose sign-in they are vouching for. The API resolves the UUID to a // linked account and refuses unless that account is staff (admin or owner) — this // check against the API's authoritative user table is the only gate; velocity's // command itself is unprivileged. type opLoginApproveRequest struct { ApproverUUID string `json:"approver_uuid"` Username string `json:"username"` } // handleOpLoginApprove records an in-game admin's vouch for a pending staff login // (internal face), supplying the second factor. It resolves the approver UUID to a // linked staff account (admin or owner; else 403), then flips the request approved. // A missing or no-longer-pending request is 404. Self-approval is allowed: a staff // member online as their own admin identity supplies a genuine second factor // (in-game session control) distinct from the mailbox factor. // linked staff account (admin or owner; else 403), requires the typed username to // name the request's account (else 409, request left pending), then flips the // request approved. A missing or no-longer-pending request is 404. Self-approval is // allowed: a staff member online as their own admin identity supplies a genuine // second factor (in-game session control) distinct from the mailbox factor. func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req opLoginApproveRequest Loading @@ -403,38 +494,33 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { return } approverUUID := strings.TrimSpace(req.ApproverUUID) if approverUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required")) typed := strings.TrimSpace(req.Username) if approverUUID == "" || typed == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid and username are required")) return } // Resolve the in-game approver to a linked account and require a staff role // (admin, or the owner superset). An unlinked UUID or a non-staff player may // never vouch for an op.console login. All three refusals share one response so // a caller cannot tell "not linked" from "linked but not staff". notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login") approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID) switch { case errors.Is(err, ErrNotFound): writeError(w, r, notAdmin) return case err != nil: approver, err := a.opLoginApprover(r, approverUUID) if err != nil { writeError(w, r, err) return } approver, err := a.Repo.UserByID(r.Context(), approverID) switch { case errors.Is(err, ErrNotFound): writeError(w, r, notAdmin) return case err != nil: loginReq, err := a.pendingOpLogin(r, id) if err != nil { writeError(w, r, err) return } if !staffRole(approver.Role) { writeError(w, r, notAdmin) // Minecraft names are case-insensitive, and so is the name an admin retypes. if !strings.EqualFold(typed, loginReq.Username) { payload, _ := json.Marshal(map[string]string{"request_id": id, "typed_username": typed}) a.auditEntry(r, AuditEntry{ Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r), Action: "auth.op_login.approve_mismatch", Payload: payload, }) writeError(w, r, newError(http.StatusConflict, "op_login_mismatch", "that operator login is for a different account")) return } switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); { switch err := a.Repo.ApproveOpLogin(r.Context(), id, approver.ID, a.now()); { case errors.Is(err, ErrNotFound): writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id")) return Loading @@ -442,10 +528,15 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID}) payload, _ := json.Marshal(map[string]string{ "request_id": id, "approver_user_id": approver.ID, "username": loginReq.Username, "client_ip": loginReq.ClientIP, }) a.auditEntry(r, AuditEntry{ Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r), Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r), Action: "auth.op_login.approved", Payload: payload, }) writeJSON(w, http.StatusOK, map[string]any{"approved": true}) writeJSON(w, http.StatusOK, map[string]any{ "approved": true, "username": loginReq.Username, "email": loginReq.Email, }) } internal/api/handlers_op_login_test.go +149 −19 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
docs/openapi.yaml +83 −9 Changes for docs/openapi.yaml: 83 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -1307,14 +1307,75 @@ paths: type: array items: type: object required: [request_id, username, email, created_at] required: [request_id, username, email, client_ip, created_at] properties: request_id: { type: string } username: { type: string } email: { type: string } client_ip: type: string description: Where start was called from; empty on requests from before this was recorded. created_at: { type: string, format: date-time } '401': $ref: '#/components/responses/Unauthorized' /api/v1/internal/op-login/{id}: get: tags: [account-internal] operationId: opLoginShow summary: Show an in-game admin whose op.console login a request is (spec §B). description: > Internal-only. velocity's /felis web op approve <code> reads this and shows the admin the account, its address, and when and from where the sign-in was started, then asks them to confirm by typing the account name (see approve). The approver's online-mode UUID gets the same check as approve (a linked admin or owner, else 403 not_admin), since the command runs for any player and a staff address must not be readable by one. A request that is unknown, expired, approved or consumed is 404. x-felis-face: [internal] x-felis-tier: service x-felis-callers: [velocity] security: [{ serviceToken: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: approver_uuid, in: query, required: true, schema: { type: string, format: uuid } } responses: '200': description: The pending request and where it was started. content: application/json: schema: type: object required: [request_id, username, email, client_ip, user_agent, created_at, expires_at] properties: request_id: { type: string } username: { type: string } email: { type: string } client_ip: type: string description: Where start was called from; empty on requests from before this was recorded. user_agent: type: string description: The browser's User-Agent at start, up to 256 bytes; may be empty. created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } '400': description: approver_uuid is required (bad_request). content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': description: The approver is not a linked administrator (not_admin). content: application/json: schema: { $ref: '#/components/schemas/Error' } '404': description: No pending operator login with that id (op_login_not_found). content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/op-login/{id}/approve: post: Loading @@ -1323,11 +1384,14 @@ paths: summary: Record an in-game admin's vouch for a pending op.console login (spec §B). description: > Internal-only second factor: velocity submits the online-mode UUID of the in-game admin running /felis web op approve. The API resolves it to a linked role=admin account (else 403 not_admin) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. in-game admin running /felis web op approve <code> <username>, and the account name they typed after seeing the request (GET /api/v1/internal/op-login/{id}). The API resolves the UUID to a linked admin or owner account (else 403 not_admin), requires the typed name to match the request's account ignoring case (else 409 op_login_mismatch, audited, request left pending) and flips the request approved. A missing or no-longer-pending request is 404. Self-approval is allowed — an online staff member vouching as their own admin identity is a genuine second factor distinct from the mailbox. x-felis-face: [internal] x-felis-tier: service x-felis-callers: [velocity] Loading @@ -1340,9 +1404,12 @@ paths: application/json: schema: type: object required: [approver_uuid] required: [approver_uuid, username] properties: approver_uuid: { type: string, format: uuid } username: type: string description: The account name the admin typed to confirm whose sign-in this is. responses: '200': description: The vouch was recorded; the request is now approved. Loading @@ -1350,11 +1417,13 @@ paths: application/json: schema: type: object required: [approved] required: [approved, username, email] properties: approved: { type: boolean, const: true } username: { type: string } email: { type: string } '400': description: approver_uuid is required (bad_request). description: approver_uuid and username are required (bad_request). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading @@ -1370,6 +1439,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } '409': description: The typed name is not the request's account (op_login_mismatch). content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/servers/{name}/backup: post: Loading
internal/api/api.go +1 −0 Changes for internal/api/api.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -434,6 +434,7 @@ func (a *API) internalAPIRoutes() []apiRoute { // approve action (service-token auth, no Principal); the public face carries // the start/status/finish the staff member's browser drives. {Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending}, {Method: "GET", Pattern: "/api/v1/internal/op-login/{id}", Callers: proxy, h: a.handleOpLoginShow}, {Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", Callers: proxy, h: a.handleOpLoginApprove}, // Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to Loading
internal/api/api_test.go +16 −13 Changes for internal/api/api_test.go: 16 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -263,6 +263,8 @@ type fakeOpLogin struct { consumed bool expiresAt time.Time createdAt time.Time clientIP string userAgent string } // fakeSetupToken mirrors a setup_tokens row (spec §B setup): a one-time Loading Loading @@ -1606,25 +1608,27 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code // CreateOpLoginRequest records a fresh pending op.console login attempt. status is // born 'pending'; createdAt orders the pending list (the PG ORDER BY created_at). func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, id, userID, email string, expiresAt time.Time) error { f.opLogins[id] = &fakeOpLogin{ id: id, userID: userID, email: email, status: "pending", expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, req NewOpLoginRequest) error { f.opLogins[req.ID] = &fakeOpLogin{ id: req.ID, userID: req.UserID, email: req.Email, status: "pending", expiresAt: req.ExpiresAt, createdAt: req.ExpiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation clientIP: req.ClientIP, userAgent: req.UserAgent, } return nil } // OpLoginRequestByID loads a request by handle, projecting the fake row into the // OpLoginRequest the status/finish paths read (Status, Consumed, ExpiresAt). Status // is the (approved_at, denied_at) projection the handler gates on. // OpLoginRequest the handlers read, with the username joined like the PG query. // Status is the (approved_at, denied_at) projection the handler gates on. func (f *fakeRepo) OpLoginRequestByID(_ context.Context, id string) (*OpLoginRequest, error) { r, ok := f.opLogins[id] if !ok { return nil, ErrNotFound } return &OpLoginRequest{ ID: r.id, UserID: r.userID, Email: r.email, ExpiresAt: r.expiresAt, Status: r.status, Consumed: r.consumed, ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email, ExpiresAt: r.expiresAt, CreatedAt: r.createdAt, Status: r.status, Consumed: r.consumed, ClientIP: r.clientIP, UserAgent: r.userAgent, }, nil } Loading @@ -1644,8 +1648,7 @@ func (f *fakeRepo) ConsumeOpLoginRequest(_ context.Context, id string, now time. // ListPendingOpLogins returns the live (pending, unconsumed, unexpired) requests // oldest-first, mirroring the PG WHERE consumed_at IS NULL AND approved_at IS NULL // AND expires_at > now ORDER BY created_at. Username is joined from the staff map // (the in-game admin needs to name who is waiting), exactly as the repo.go contract // documents — ListPendingOpLogins is the ONLY path that populates Username. // (the in-game admin needs to name who is waiting). func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLoginRequest, error) { var out []OpLoginRequest for _, r := range f.opLogins { Loading @@ -1655,6 +1658,7 @@ func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLo out = append(out, OpLoginRequest{ ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID), Email: r.email, ExpiresAt: r.expiresAt, Status: "pending", CreatedAt: r.createdAt, ClientIP: r.clientIP, UserAgent: r.userAgent, }) } sort.Slice(out, func(i, j int) bool { Loading Loading @@ -1690,9 +1694,8 @@ func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now ti return tok.UserID, nil } // usernameFor joins a userID to its staff username (the ListPendingOpLogins // projection the in-game admin needs to name who is waiting). "" when the user is // gone — mirroring a missing JOIN row. // usernameFor joins a userID to its staff username (the op-login projection the // in-game admin needs to name who is waiting). "" when the user is gone. func (f *fakeRepo) usernameFor(userID string) string { for _, u := range f.staff { if u.ID == userID { Loading
internal/api/handlers_op_login.go +127 −36 Changes for internal/api/handlers_op_login.go: 127 added lines, 36 removed lines. Original line number Diff line number Diff line Loading @@ -11,14 +11,15 @@ import ( // sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind // code), a staff web session is never minted from a single factor. The flow is a // three-call state machine over op_login_requests (migration 0016), all Public // pre-session routes (the caller has no principal yet), plus two internal-face routes // for the in-game side (approve is driven by velocity's /felis command; pending has // no consumer yet — see handleOpLoginPending): // pre-session routes (the caller has no principal yet), plus internal-face routes // for the in-game side (show and approve are driven by velocity's /felis command; // pending has no consumer yet — see handleOpLoginPending): // // POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP // GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves // POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session // GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch // GET /api/v1/internal/op-login/{id} (internal) — who a request is for, shown to the admin // POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches // // The two factors: Loading @@ -30,6 +31,9 @@ import ( // request via velocity's /felis command (internal approve). The API's own user // table is the sole authority: only a UUID linked to a staff account may // approve (velocity's command runs for any player and relies on this check). // The admin first sees whose request it is (account, address, where it was // started) and approves by typing that account's name, so a code relayed by a // stranger ("please approve abc123") cannot be vouched for blind. // // finish mints the session only when BOTH have landed. Neither factor alone — a mailed // code without an approval, or an approval without the code — yields a session. Loading Loading @@ -173,7 +177,14 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil { origin := "" if addr := a.clientIP(r); addr.IsValid() { origin = addr.String() } if err := a.Repo.CreateOpLoginRequest(r.Context(), NewOpLoginRequest{ ID: id, UserID: u.ID, Email: u.Email, ExpiresAt: expiresAt, ClientIP: origin, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent), }); err != nil { writeError(w, r, err) return } Loading Loading @@ -375,26 +386,106 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) { "request_id": req.ID, "username": req.Username, "email": req.Email, "client_ip": req.ClientIP, "created_at": req.CreatedAt.UTC(), }) } writeJSON(w, http.StatusOK, map[string]any{"pending": out}) } // opLoginApprover resolves the in-game player running /felis web op approve to a // linked staff account (admin, or the owner superset). An unlinked UUID or a // non-staff player may never see or vouch for an op.console login; all refusals // share one 403 so a caller cannot tell "not linked" from "linked but not staff". func (a *API) opLoginApprover(r *http.Request, mcUUID string) (*StaffUser, error) { notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login") approverID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) switch { case errors.Is(err, ErrNotFound): return nil, notAdmin case err != nil: return nil, err } approver, err := a.Repo.UserByID(r.Context(), approverID) switch { case errors.Is(err, ErrNotFound): return nil, notAdmin case err != nil: return nil, err } if !staffRole(approver.Role) { return nil, notAdmin } return approver, nil } // pendingOpLogin loads a request an admin may still vouch for: pending, unconsumed // and unexpired. Anything else is the same 404 the approve race returns. func (a *API) pendingOpLogin(r *http.Request, id string) (*OpLoginRequest, error) { notFound := newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id") req, err := a.Repo.OpLoginRequestByID(r.Context(), id) switch { case errors.Is(err, ErrNotFound): return nil, notFound case err != nil: return nil, err } if req.Status != "pending" || req.Consumed || !req.ExpiresAt.After(a.now()) { return nil, notFound } return req, nil } // handleOpLoginShow tells the in-game admin who a pending request is for before // they vouch (internal face): the account, its address, when and from where the // sign-in was started. velocity's /felis web op approve <code> renders this and // asks the admin to confirm by name. The approver UUID rides in the query and gets // the same staff check as approve, since velocity's command runs for any player and // a staff address must not be readable by one. func (a *API) handleOpLoginShow(w http.ResponseWriter, r *http.Request) { approverUUID := strings.TrimSpace(r.URL.Query().Get("approver_uuid")) if approverUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required")) return } if _, err := a.opLoginApprover(r, approverUUID); err != nil { writeError(w, r, err) return } req, err := a.pendingOpLogin(r, r.PathValue("id")) if err != nil { writeError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{ "request_id": req.ID, "username": req.Username, "email": req.Email, "client_ip": req.ClientIP, "user_agent": req.UserAgent, "created_at": req.CreatedAt.UTC(), "expires_at": req.ExpiresAt.UTC(), }) } // opLoginApproveRequest is the internal approve body: the online-mode UUID of the // in-game admin running /felis web op approve. The API resolves it to a linked account // and refuses unless that account is staff (admin or owner) — this check against the API's // authoritative user table is the only gate; velocity's command itself is unprivileged. // in-game admin running /felis web op approve, and the account name they typed to // confirm whose sign-in they are vouching for. The API resolves the UUID to a // linked account and refuses unless that account is staff (admin or owner) — this // check against the API's authoritative user table is the only gate; velocity's // command itself is unprivileged. type opLoginApproveRequest struct { ApproverUUID string `json:"approver_uuid"` Username string `json:"username"` } // handleOpLoginApprove records an in-game admin's vouch for a pending staff login // (internal face), supplying the second factor. It resolves the approver UUID to a // linked staff account (admin or owner; else 403), then flips the request approved. // A missing or no-longer-pending request is 404. Self-approval is allowed: a staff // member online as their own admin identity supplies a genuine second factor // (in-game session control) distinct from the mailbox factor. // linked staff account (admin or owner; else 403), requires the typed username to // name the request's account (else 409, request left pending), then flips the // request approved. A missing or no-longer-pending request is 404. Self-approval is // allowed: a staff member online as their own admin identity supplies a genuine // second factor (in-game session control) distinct from the mailbox factor. func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req opLoginApproveRequest Loading @@ -403,38 +494,33 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { return } approverUUID := strings.TrimSpace(req.ApproverUUID) if approverUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required")) typed := strings.TrimSpace(req.Username) if approverUUID == "" || typed == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid and username are required")) return } // Resolve the in-game approver to a linked account and require a staff role // (admin, or the owner superset). An unlinked UUID or a non-staff player may // never vouch for an op.console login. All three refusals share one response so // a caller cannot tell "not linked" from "linked but not staff". notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login") approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID) switch { case errors.Is(err, ErrNotFound): writeError(w, r, notAdmin) return case err != nil: approver, err := a.opLoginApprover(r, approverUUID) if err != nil { writeError(w, r, err) return } approver, err := a.Repo.UserByID(r.Context(), approverID) switch { case errors.Is(err, ErrNotFound): writeError(w, r, notAdmin) return case err != nil: loginReq, err := a.pendingOpLogin(r, id) if err != nil { writeError(w, r, err) return } if !staffRole(approver.Role) { writeError(w, r, notAdmin) // Minecraft names are case-insensitive, and so is the name an admin retypes. if !strings.EqualFold(typed, loginReq.Username) { payload, _ := json.Marshal(map[string]string{"request_id": id, "typed_username": typed}) a.auditEntry(r, AuditEntry{ Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r), Action: "auth.op_login.approve_mismatch", Payload: payload, }) writeError(w, r, newError(http.StatusConflict, "op_login_mismatch", "that operator login is for a different account")) return } switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); { switch err := a.Repo.ApproveOpLogin(r.Context(), id, approver.ID, a.now()); { case errors.Is(err, ErrNotFound): writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id")) return Loading @@ -442,10 +528,15 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID}) payload, _ := json.Marshal(map[string]string{ "request_id": id, "approver_user_id": approver.ID, "username": loginReq.Username, "client_ip": loginReq.ClientIP, }) a.auditEntry(r, AuditEntry{ Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r), Actor: approver.Username, ActorUserID: approver.ID, Source: internalSource(r), Action: "auth.op_login.approved", Payload: payload, }) writeJSON(w, http.StatusOK, map[string]any{"approved": true}) writeJSON(w, http.StatusOK, map[string]any{ "approved": true, "username": loginReq.Username, "email": loginReq.Email, }) }
internal/api/handlers_op_login_test.go +149 −19 File changed.Preview size limit exceeded, changes collapsed. Show changes