feat(admin): add protected account lifecycle endpoints (#149)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-23 20:10:35 -04:00
1 parent 951b79bf6e
commit 4d2642abec
10 files changed
+1352 -3

No files matched your search

+16 -2
View File
@@ -133,8 +133,7 @@ broker workers, not connection status or a tunable processing-worker pool).
The CORS lists are the options supplied to the middleware; its normal matching
normalization still applies. The response excludes
credential fields, broker addresses, channel material, database settings and
other configuration. Account operations are not implemented; unknown admin paths
return 404 and unsupported
other configuration. Unknown admin paths return 404 and unsupported
methods on the config endpoint return 405 after authentication.
Global CORS preflights remain public. Use a long, randomly generated key, keep
it out of source control and logs, and send it only in the Authorization header,
@@ -158,6 +157,21 @@ auth/credential fields and broker count cannot be changed here; there is no
configurable `ingest.worker_count`. Cross-origin admin clients need PUT allowed
in the saved CORS methods. CORS controls browser access, not authentication.
Operator accounts are available at `GET/POST /api/v1/admin/accounts` and
`GET/DELETE /api/v1/admin/accounts/{id}`. POST accepts a JSON `name` field in a
body up to 4 KiB; names are trimmed, case-sensitive and limited to 128 Unicode
characters without control characters. Active names are unique. DELETE soft
deactivates the record (204); missing IDs return 404 and an already inactive
record returns 409. A deactivated name may be reused by a new account.
Lists include active and inactive records, newest first, without pagination.
These are operator-defined records; no login, session or API token is created.
Cross-origin account clients need both `POST` and `DELETE` in the saved
`cors.allowed_methods`; the default `GET, HEAD, OPTIONS` is read-only. For an
admin UI that also updates configuration, use `[GET, HEAD, OPTIONS, POST, PUT,
DELETE]`, restrict `cors.allowed_origins` to that UI, and allow `Authorization`
and `Content-Type` headers. Otherwise browser preflight blocks these requests
even when the same bearer-authenticated request works with curl.
### Environment variables (`.env`)
| Variable | Default | Description |
+2
View File
@@ -18,6 +18,7 @@ import (
"github.com/MeshCore-Beacon/beacon-server/db"
_ "github.com/MeshCore-Beacon/beacon-server/docs"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
"github.com/MeshCore-Beacon/beacon-server/internal/background"
"github.com/MeshCore-Beacon/beacon-server/internal/cache"
@@ -307,6 +308,7 @@ func main() {
MaxConnsPerIP: resolved.MaxConnsPerIP,
MaxConnectsPerMinute: resolved.MaxConnectsPerMinute,
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)},
})
srv := &http.Server{
+1 -1
View File
@@ -166,7 +166,7 @@ websocket:
#cors:
# allowed_origins:
# - "https://app.example.com"
# allowed_methods: [GET, HEAD, OPTIONS, POST]
# allowed_methods: [GET, HEAD, OPTIONS, POST, PUT, DELETE] # admin UI writes
# allowed_headers: [Accept, Authorization, Content-Type]
# allow_credentials: false
# max_age: 300
+358
View File
@@ -22,6 +22,322 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/admin/accounts": {
"get": {
"security": [
{
"AdminKey": []
}
],
"description": "Includes active and inactive accounts, newest first. These records are not login users.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "List operator accounts",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"post": {
"security": [
{
"AdminKey": []
}
],
"description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Create an operator account",
"parameters": [
{
"description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"413": {
"description": "Request Entity Too Large",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"415": {
"description": "Unsupported Media Type",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
}
},
"/admin/accounts/{id}": {
"get": {
"security": [
{
"AdminKey": []
}
],
"description": "Returns active or inactive account records.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Get an operator account",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account UUID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"delete": {
"security": [
{
"AdminKey": []
}
],
"description": "Soft deactivation preserves the record. Its name may be reused by a new account.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Deactivate an operator account",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account UUID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Deactivated"
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
}
},
"/admin/config": {
"get": {
"security": [
@@ -2640,6 +2956,37 @@ const docTemplate = `{
}
},
"definitions": {
"github_com_MeshCore-Beacon_beacon-server_internal_api.Account": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"deactivated_at": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": {
"type": "object",
"properties": {
@@ -2893,6 +3240,17 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
"type": "object",
"properties": {
+358
View File
@@ -20,6 +20,322 @@
"host": "localhost:8080",
"basePath": "/api/v1",
"paths": {
"/admin/accounts": {
"get": {
"security": [
{
"AdminKey": []
}
],
"description": "Includes active and inactive accounts, newest first. These records are not login users.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "List operator accounts",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"post": {
"security": [
{
"AdminKey": []
}
],
"description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Create an operator account",
"parameters": [
{
"description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"413": {
"description": "Request Entity Too Large",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"415": {
"description": "Unsupported Media Type",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
}
},
"/admin/accounts/{id}": {
"get": {
"security": [
{
"AdminKey": []
}
],
"description": "Returns active or inactive account records.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Get an operator account",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account UUID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"delete": {
"security": [
{
"AdminKey": []
}
],
"description": "Soft deactivation preserves the record. Its name may be reused by a new account.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Deactivate an operator account",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account UUID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Deactivated"
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
}
},
"/admin/config": {
"get": {
"security": [
@@ -2638,6 +2954,37 @@
}
},
"definitions": {
"github_com_MeshCore-Beacon_beacon-server_internal_api.Account": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"deactivated_at": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
}
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": {
"type": "object",
"properties": {
@@ -2891,6 +3238,17 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
"type": "object",
"properties": {
+236
View File
@@ -1,5 +1,25 @@
basePath: /api/v1
definitions:
github_com_MeshCore-Beacon_beacon-server_internal_api.Account:
properties:
active:
type: boolean
created_at:
type: string
deactivated_at:
type: string
id:
type: string
name:
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList:
properties:
items:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
type: array
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig:
properties:
configured:
@@ -177,6 +197,13 @@ definitions:
nodeTypeName:
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest:
properties:
name:
type: string
required:
- name
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop:
properties:
fromIata:
@@ -1289,6 +1316,215 @@ info:
title: MeshCore Beacon API
version: 1.6.0
paths:
/admin/accounts:
get:
description: Includes active and inactive accounts, newest first. These records
are not login users.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList'
"401":
description: Unauthorized
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
security:
- AdminKey: []
summary: List operator accounts
tags:
- Admin
post:
consumes:
- application/json
description: Trims the name; names are case-sensitive and unique among active
accounts. No credential or login is created.
parameters:
- description: Account name (1-128 Unicode characters, no control characters);
body at most 4 KiB
in: body
name: account
required: true
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
"400":
description: Bad Request
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"409":
description: Conflict
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"413":
description: Request Entity Too Large
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"415":
description: Unsupported Media Type
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
security:
- AdminKey: []
summary: Create an operator account
tags:
- Admin
/admin/accounts/{id}:
delete:
description: Soft deactivation preserves the record. Its name may be reused
by a new account.
parameters:
- description: Account UUID
format: uuid
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: Deactivated
"400":
description: Bad Request
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"404":
description: Not Found
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"409":
description: Conflict
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
security:
- AdminKey: []
summary: Deactivate an operator account
tags:
- Admin
get:
description: Returns active or inactive account records.
parameters:
- description: Account UUID
format: uuid
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
"400":
description: Bad Request
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"404":
description: Not Found
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
"503":
description: Service Unavailable
schema:
additionalProperties:
$ref: '#/definitions/internal_api_handlers.APIError'
type: object
security:
- AdminKey: []
summary: Get an operator account
tags:
- Admin
/admin/config:
get:
description: Returns current CORS options, auth configuration status and configured
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
type CreateAccountRequest struct {
Name string `json:"name" validate:"required"`
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// AccountsRouter must be mounted inside the authenticated admin subtree.
func AccountsRouter(store api.AccountStore) http.Handler {
if store == nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respondError(w, 503, "account storage is unavailable") })
}
r := chi.NewRouter()
r.Get("/", listAccounts(store))
r.Post("/", createAccount(store))
r.Get("/{id}", getAccount(store))
r.Delete("/{id}", deactivateAccount(store))
return r
}
// listAccounts godoc
// @Summary List operator accounts
// @Description Includes active and inactive accounts, newest first. These records are not login users.
// @Tags Admin
// @Produce json
// @Security AdminKey
// @Success 200 {object} api.AccountList
// @Failure 401,503,500 {object} map[string]APIError
// @Router /admin/accounts [get]
func listAccounts(store api.AccountStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
items, err := store.ListAccounts(r.Context())
if err != nil {
accountError(w, err)
return
}
if items == nil {
items = []api.Account{}
}
respond(w, 200, api.AccountList{Items: items})
}
}
// createAccount godoc
// @Summary Create an operator account
// @Description Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.
// @Tags Admin
// @Accept json
// @Produce json
// @Security AdminKey
// @Param account body api.CreateAccountRequest true "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB"
// @Success 201 {object} api.Account
// @Failure 400,401,409,413,415,503,500 {object} map[string]APIError
// @Router /admin/accounts [post]
func createAccount(store api.AccountStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
respondError(w, 415, "Content-Type must be application/json")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var input api.CreateAccountRequest
err = decoder.Decode(&input)
if err == nil {
var extra any
err = decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
err = nil
} else if err == nil {
err = errors.New("multiple JSON values")
}
}
if err != nil {
var sizeError *http.MaxBytesError
if errors.As(err, &sizeError) {
respondError(w, 413, "request body exceeds 4 KiB")
} else {
respondError(w, 400, "invalid account JSON")
}
return
}
name, err := api.NormalizeAccountName(input.Name)
if err != nil {
accountError(w, err)
return
}
account, err := store.CreateAccount(r.Context(), name)
if err != nil {
accountError(w, err)
return
}
respond(w, 201, account)
}
}
// getAccount godoc
// @Summary Get an operator account
// @Description Returns active or inactive account records.
// @Tags Admin
// @Produce json
// @Security AdminKey
// @Param id path string true "Account UUID" format(uuid)
// @Success 200 {object} api.Account
// @Failure 400,401,404,503,500 {object} map[string]APIError
// @Router /admin/accounts/{id} [get]
func getAccount(store api.AccountStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, 400, "invalid account ID")
return
}
account, err := store.GetAccount(r.Context(), id)
if err != nil {
accountError(w, err)
return
}
respond(w, 200, account)
}
}
// deactivateAccount godoc
// @Summary Deactivate an operator account
// @Description Soft deactivation preserves the record. Its name may be reused by a new account.
// @Tags Admin
// @Produce json
// @Security AdminKey
// @Param id path string true "Account UUID" format(uuid)
// @Success 204 "Deactivated"
// @Failure 400,401,404,409,503,500 {object} map[string]APIError
// @Router /admin/accounts/{id} [delete]
func deactivateAccount(store api.AccountStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, 400, "invalid account ID")
return
}
if err := store.DeactivateAccount(r.Context(), id); err != nil {
accountError(w, err)
return
}
w.WriteHeader(204)
}
}
func accountError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, api.ErrAccountNameInvalid):
respondError(w, 400, api.ErrAccountNameInvalid.Error())
case errors.Is(err, api.ErrAccountNameConflict):
respondError(w, 409, api.ErrAccountNameConflict.Error())
case errors.Is(err, api.ErrAccountNotFound):
respondError(w, 404, api.ErrAccountNotFound.Error())
case errors.Is(err, api.ErrAccountInactive):
respondError(w, 409, api.ErrAccountInactive.Error())
default:
respondError(w, 500, "internal server error")
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware"
"github.com/google/uuid"
)
type accountStub struct {
account api.Account
items []api.Account
err error
calls int
name string
id uuid.UUID
}
func (s *accountStub) CreateAccount(_ context.Context, name string) (api.Account, error) {
s.calls++
s.name = name
return s.account, s.err
}
func (s *accountStub) ListAccounts(context.Context) ([]api.Account, error) {
s.calls++
return s.items, s.err
}
func (s *accountStub) GetAccount(_ context.Context, id uuid.UUID) (api.Account, error) {
s.calls++
s.id = id
return s.account, s.err
}
func (s *accountStub) DeactivateAccount(_ context.Context, id uuid.UUID) error {
s.calls++
s.id = id
return s.err
}
func accountRequest(handler http.Handler, method, path, body, contentType, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func TestAccountInputAndErrors(t *testing.T) {
id := uuid.New()
for _, tc := range []struct {
name, method, path, body, media string
err error
status, calls int
}{
{"create", "POST", "/", `{"name":" Alice "}`, "application/json", nil, 201, 1},
{"media parameters", "POST", "/", `{"name":"Alice"}`, "application/json; charset=utf-8", nil, 201, 1},
{"empty", "POST", "/", `{}`, "application/json", nil, 400, 0},
{"null", "POST", "/", `null`, "application/json", nil, 400, 0},
{"wrong field", "POST", "/", `{"name":"Alice","admin":true}`, "application/json", nil, 400, 0},
{"trailing JSON", "POST", "/", `{"name":"Alice"} {}`, "application/json", nil, 400, 0},
{"malformed", "POST", "/", `{"name":`, "application/json", nil, 400, 0},
{"wrong name type", "POST", "/", `{"name":3}`, "application/json", nil, 400, 0},
{"control", "POST", "/", `{"name":"x\u0000y"}`, "application/json", nil, 400, 0},
{"oversized", "POST", "/", `{"name":"` + strings.Repeat("a", 5000) + `"}`, "application/json", nil, 413, 0},
{"oversized trailing space", "POST", "/", `{"name":"Alice"}` + strings.Repeat(" ", 5000), "application/json", nil, 413, 0},
{"missing media", "POST", "/", `{"name":"Alice"}`, "", nil, 415, 0},
{"wrong media", "POST", "/", `{"name":"Alice"}`, "text/plain", nil, 415, 0},
{"conflict", "POST", "/", `{"name":"Alice"}`, "application/json", fmt.Errorf("private-error: %w", api.ErrAccountNameConflict), 409, 1},
{"get", "GET", "/" + id.String(), "", "", nil, 200, 1},
{"bad ID", "GET", "/invalid", "", "", nil, 400, 0},
{"bad delete ID", "DELETE", "/invalid", "", "", nil, 400, 0},
{"not found", "GET", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1},
{"deactivate", "DELETE", "/" + id.String(), "", "", nil, 204, 1},
{"inactive", "DELETE", "/" + id.String(), "", "", api.ErrAccountInactive, 409, 1},
{"delete absent", "DELETE", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1},
{"list database failure", "GET", "/", "", "", errors.New("private-error"), 500, 1},
} {
t.Run(tc.name, func(t *testing.T) {
store := &accountStub{account: api.Account{ID: id, Name: "Alice", CreatedAt: time.Unix(1, 0).UTC(), Active: true}, err: tc.err}
handler := mw.BearerAuth("test-key", AccountsRouter(store))
w := accountRequest(handler, tc.method, tc.path, tc.body, tc.media, "test-key")
if w.Code != tc.status || store.calls != tc.calls {
t.Fatalf("status=%d calls=%d", w.Code, store.calls)
}
if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), "private-error") {
t.Fatal("cache policy or error disclosure")
}
if tc.status == 204 {
if w.Body.Len() != 0 {
t.Fatal("204 body")
}
return
}
if w.Header().Get("Content-Type") != "application/json" || !json.Valid(w.Body.Bytes()) {
t.Fatal("invalid JSON response")
}
if tc.status == 201 && store.name != "Alice" {
t.Fatal("untrimmed name reached storage")
}
if (tc.method == "DELETE" || tc.method == "GET") && tc.calls > 0 && tc.path != "/" && store.id != id {
t.Fatal("wrong account ID")
}
})
}
}
func TestAccountListAndAuthorization(t *testing.T) {
for _, items := range [][]api.Account{nil, {{ID: uuid.New(), Name: "inactive", Active: false}}} {
store := &accountStub{items: items}
handler := mw.BearerAuth("test-key", AccountsRouter(store))
w := accountRequest(handler, "GET", "/", "", "", "test-key")
var body api.AccountList
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Items == nil || len(body.Items) != len(items) {
t.Fatal("account list shape")
}
}
for _, key := range []string{"", "test-key"} {
store := &accountStub{}
handler := mw.BearerAuth(key, AccountsRouter(store))
for _, method := range []string{"GET", "POST", "DELETE"} {
w := accountRequest(handler, method, "/", "{}", "application/json", "")
want := 401
if key == "" {
want = 503
}
if w.Code != want || store.calls != 0 {
t.Fatal("unauthorized store access")
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package router
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/google/uuid"
)
type routedAccountStore struct {
api.AccountStore
calls int
}
func (s *routedAccountStore) CreateAccount(_ context.Context, name string) (api.Account, error) {
s.calls++
return api.Account{ID: uuid.New(), Name: name, Active: true}, nil
}
func TestAccountRouterUsesProtectedStore(t *testing.T) {
store := &routedAccountStore{}
r := New(nil, nil, nil, Options{Auth: config.AuthConfig{APIKey: "test-key"}, AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)}})
for _, token := range []string{"", "wrong", "test-key"} {
req := httptest.NewRequest("POST", "/api/v1/admin/accounts", strings.NewReader(`{"name":"test"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
want := 401
if token == "test-key" {
want = 201
}
if w.Code != want {
t.Fatalf("route status=%d want=%d", w.Code, want)
}
}
if store.calls != 1 {
t.Fatal("auth boundary was bypassed")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/accounts", nil))
if w.Code != 404 || store.calls != 1 {
t.Fatal("accounts mounted publicly")
}
}