feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

View File

@@ -1,9 +1,19 @@
package cabana
// Admin API annotations. swag reads these with the handler package so
// docs/openapi.json lists every D-09 route. The functions are not mounted;
// service.mount in http.go is the runtime route table, and
// TestPhase09PermissionMatrix fails if the two lists diverge.
// @title SummerCMS Admin API
// @version 1
// @description Framework admin API consumed by the embedded admin SPA. Every path is relative to {backend.uri}/api/v1 (for example /backend/api/v1). The SPA authenticates with the HttpOnly summer_admin cookie set by a login that sends X-Requested-With: XMLHttpRequest, and sends that header on every request; CLI clients and tests send the BackendBearer Authorization header instead.
// @BasePath /
// @securityDefinitions.apikey BackendBearer
// @in header
// @name Authorization
// @description Backend admin bearer token. Send "Bearer {access_token}".
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
// are generated from (D-15). The functions are not mounted; service.mount in
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus
// TestPhase09ContractInventory fail if the two lists diverge.
// ErrorBody is one D-10 error object.
type ErrorBody struct {
@@ -32,41 +42,84 @@ type SuccessEnvelope struct {
Meta SuccessMeta `json:"meta"`
}
// AdminLoginData is the admin login payload.
// AdminLoginData is the admin login and refresh payload. Bearer transport
// carries access_token; cookie transport (X-Requested-With: XMLHttpRequest)
// carries token_type "cookie" and expires_in, never the token.
type AdminLoginData struct {
AccessToken string `json:"access_token"`
AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in,omitempty"`
}
// AdminLoginEnvelope is the admin login success body.
type AdminLoginEnvelope struct {
Data AdminLoginData `json:"data"`
Meta SuccessMeta `json:"meta"`
// Envelope is the typed D-10 success envelope.
type Envelope[T any] struct {
Data T `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// AdminLogin documents POST /_admin/api/v1/auth/login.
// ListEnvelope is the typed D-10 paginated envelope (Phase 9 D-11 meta).
type ListEnvelope[T any] struct {
Data T `json:"data"`
Meta ListMeta `json:"meta"`
}
// AdminRecord is one admin record: a string-keyed map read through its
// list or form schema (D-16).
type AdminRecord map[string]any
// AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user.
type AdminLoginRequest struct {
Login string `json:"login,omitempty"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
}
// AdminRoleSummary is the role attached to an admin profile.
type AdminRoleSummary struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
// AdminProfile is the GET /auth/me payload.
type AdminProfile struct {
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsSuperuser bool `json:"is_superuser"`
Role *AdminRoleSummary `json:"role,omitempty"`
}
// AdminLogin documents POST /auth/login.
//
// @Summary Admin login
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Param body body AdminLoginRequest true "Credentials"
// @Param X-Requested-With header string false "XMLHttpRequest selects cookie transport"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/login [post]
// @Router /auth/login [post]
func AdminLogin() {}
// AdminRefresh documents POST /_admin/api/v1/auth/refresh.
// AdminRefresh documents POST /auth/refresh.
//
// @Summary Refresh an admin token
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Param X-Requested-With header string false "XMLHttpRequest; required unless a Bearer token is sent"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 403 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/refresh [post]
// @Router /auth/refresh [post]
func AdminRefresh() {}
// AdminLogout documents POST /_admin/api/v1/auth/logout.
// AdminLogout documents POST /auth/logout.
//
// @Summary Admin logout
// @Tags admin
@@ -74,33 +127,33 @@ func AdminRefresh() {}
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/logout [post]
// @Router /auth/logout [post]
func AdminLogout() {}
// AdminMe documents GET /_admin/api/v1/auth/me.
// AdminMe documents GET /auth/me.
//
// @Summary Current admin
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/me [get]
// @Router /auth/me [get]
func AdminMe() {}
// AdminNavigation documents GET /_admin/api/v1/navigation.
// AdminNavigation documents GET /navigation.
//
// @Summary Admin navigation
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[[]NavigationEntry]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/navigation [get]
// @Router /navigation [get]
func AdminNavigation() {}
// AdminSettingsList documents GET /_admin/api/v1/settings.
// AdminSettingsList documents GET /settings.
//
// @Summary List admin settings
// @Tags admin
@@ -109,10 +162,10 @@ func AdminNavigation() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings [get]
// @Router /settings [get]
func AdminSettingsList() {}
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema.
// AdminSettingsSchema documents GET /settings/{code}/schema.
//
// @Summary Admin settings schema
// @Tags admin
@@ -123,10 +176,10 @@ func AdminSettingsList() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code}/schema [get]
// @Router /settings/{code}/schema [get]
func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}.
// AdminSettingsGet documents GET /settings/{code}.
//
// @Summary Read admin settings
// @Tags admin
@@ -137,10 +190,10 @@ func AdminSettingsSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [get]
// @Router /settings/{code} [get]
func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}.
// AdminSettingsPut documents PUT /settings/{code}.
//
// @Summary Update admin settings
// @Tags admin
@@ -152,7 +205,7 @@ func AdminSettingsGet() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [put]
// @Router /settings/{code} [put]
func AdminSettingsPut() {}
// AdminListSchema documents the list schema route.
@@ -164,11 +217,11 @@ func AdminSettingsPut() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[ListSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get]
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
@@ -184,7 +237,7 @@ func AdminListSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get]
// @Router /{vendor}/{plugin}/{controller}/schema/form [get]
func AdminFormSchema() {}
// AdminRelationSchema documents the relation schema route.
@@ -201,7 +254,7 @@ func AdminFormSchema() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
func AdminRelationSchema() {}
// AdminList documents the record list route.
@@ -213,11 +266,16 @@ func AdminRelationSchema() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Param search query string false "Search term"
// @Param sort query string false "Sort column"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get]
// @Router /{vendor}/{plugin}/{controller} [get]
func AdminList() {}
// AdminCreate documents the record create route.
@@ -234,7 +292,7 @@ func AdminList() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post]
// @Router /{vendor}/{plugin}/{controller} [post]
func AdminCreate() {}
// AdminBulkDelete documents the bulk delete route.
@@ -251,7 +309,7 @@ func AdminCreate() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post]
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {}
// AdminShow documents the record show route.
@@ -268,7 +326,7 @@ func AdminBulkDelete() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get]
// @Router /{vendor}/{plugin}/{controller}/{id} [get]
func AdminShow() {}
// AdminUpdate documents the record update route.
@@ -286,7 +344,7 @@ func AdminShow() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put]
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
func AdminUpdate() {}
// AdminDelete documents the record delete route.
@@ -303,7 +361,7 @@ func AdminUpdate() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete]
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
func AdminDelete() {}
// AdminRelationLinked documents the linked-relation route.
@@ -320,7 +378,7 @@ func AdminDelete() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
func AdminRelationLinked() {}
// AdminRelationCandidates documents the relation candidate route.
@@ -337,7 +395,7 @@ func AdminRelationLinked() {}
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
func AdminRelationCandidates() {}
// AdminRelationLink documents the relation link route.
@@ -356,7 +414,7 @@ func AdminRelationCandidates() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
func AdminRelationLink() {}
// AdminRelationUnlink documents the relation unlink route.
@@ -375,5 +433,5 @@ func AdminRelationLink() {}
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
func AdminRelationUnlink() {}