473 lines
14 KiB
Go

package main
import (
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
var themeHexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
var requiredThemeColors = []string{
"background",
"foreground",
"primary",
"primaryForeground",
"muted",
"mutedForeground",
"border",
"accent",
}
type authoringIdentity struct {
UserID string
TenantID string
}
type themeValidationResult struct {
Valid bool `json:"valid" bson:"valid"`
Errors []string `json:"errors" bson:"errors"`
Warnings []string `json:"warnings" bson:"warnings"`
}
func (a *app) createAuthoringRun(w http.ResponseWriter, r *http.Request) {
var payload struct {
Brief map[string]any `json:"brief"`
}
if err := decodeJSON(r, &payload); err != nil || len(payload.Brief) == 0 {
writeError(w, http.StatusBadRequest, "brief is required")
return
}
run, err := a.startAuthoringRun(r, Doc(payload.Brief))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusCreated, run)
}
func (a *app) getAuthoringRun(w http.ResponseWriter, r *http.Request) {
run, err := a.getOwnedAuthoringRun(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) proposeAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) {
var payload struct {
IdempotencyKey string `json:"idempotencyKey"`
Theme map[string]any `json:"theme"`
}
if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Theme) == 0 {
writeError(w, http.StatusBadRequest, "idempotencyKey and theme are required")
return
}
run, err := a.proposeAuthoringTheme(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Theme))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) validateAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) {
result, err := a.validateAuthoringTheme(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) deleteAuthoringRun(w http.ResponseWriter, r *http.Request) {
result, err := a.discardAuthoringRun(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAuthoringError(w http.ResponseWriter, err error) {
switch err.Error() {
case "authentication required":
writeError(w, http.StatusUnauthorized, err.Error())
case "tenant claim required":
writeError(w, http.StatusForbidden, err.Error())
case "authoring store unavailable":
writeError(w, http.StatusServiceUnavailable, err.Error())
case "authoring run not found":
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
func themeSchemaResource() map[string]any {
properties := map[string]any{}
for _, field := range requiredThemeColors {
properties[field] = map[string]any{"type": "string", "pattern": `^#[0-9a-fA-F]{6}$`}
}
properties["radius"] = map[string]any{"type": "number", "minimum": 0, "maximum": 2}
properties["fontFamily"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 200}
return map[string]any{
"type": "object",
"properties": properties,
"required": append(requiredThemeColors, "radius", "fontFamily"),
"additionalProperties": false,
"constraints": map[string]any{
"minimumTextContrast": 4.5,
"arbitraryCSS": false,
},
}
}
func (a *app) callExternalAuthoringTool(r *http.Request, name string, arguments map[string]any) (any, error) {
switch name {
case "authoring_start":
brief, ok := asDocument(arguments["brief"])
if !ok || len(brief) == 0 {
return nil, errors.New("brief is required")
}
return a.startAuthoringRun(r, brief)
case "authoring_get_status":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.getOwnedAuthoringRun(r, runID)
case "theme_propose":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
theme, ok := asDocument(arguments["theme"])
if !ok {
return nil, errors.New("theme is required")
}
return a.proposeAuthoringTheme(r, runID, idempotencyKey, theme)
case "theme_validate":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.validateAuthoringTheme(r, runID)
case "site_propose_structure":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
site, ok := asDocument(arguments["site"])
if !ok {
return nil, errors.New("site is required")
}
return a.proposeAuthoringSiteStructure(r, runID, idempotencyKey, site)
case "site_upsert_page_proposal":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
page, ok := asDocument(arguments["page"])
if !ok {
return nil, errors.New("page is required")
}
return a.upsertAuthoringSitePage(r, runID, idempotencyKey, page)
case "site_validate":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.validateAuthoringSite(r, runID)
case "authoring_get_review_link":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.authoringReviewLink(r, runID)
case "authoring_discard":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.discardAuthoringRun(r, runID)
default:
return nil, errors.New("unknown or unavailable tool")
}
}
func (a *app) startAuthoringRun(r *http.Request, brief Doc) (Doc, error) {
identity, err := a.resolveAuthoringIdentity(r)
if err != nil {
return nil, err
}
if a.db == nil {
return nil, errors.New("authoring store unavailable")
}
return a.create("authoring_runs", Doc{
"tenantId": identity.TenantID,
"userId": identity.UserID,
"status": "draft",
"brief": brief,
"proposal": Doc{},
"proposalRevision": 0,
"validation": Doc{},
"idempotencyKeys": Doc{},
"source": "mcp",
})
}
func (a *app) proposeAuthoringTheme(r *http.Request, runID, idempotencyKey string, theme Doc) (Doc, error) {
run, identity, err := a.getMutableAuthoringRun(r, runID)
if err != nil {
return nil, err
}
keys, _ := asDocument(run["idempotencyKeys"])
if keys != nil && anyToString(keys["theme_propose"]) == idempotencyKey {
return run, nil
}
if keys == nil {
keys = Doc{}
}
keys["theme_propose"] = idempotencyKey
proposal, _ := asDocument(run["proposal"])
if proposal == nil {
proposal = Doc{}
}
proposal["theme"] = theme
return a.updateAuthoringProposal(runID, identity, run, proposal, keys)
}
func (a *app) validateAuthoringTheme(r *http.Request, runID string) (Doc, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
proposal, _ := asDocument(run["proposal"])
theme, ok := asDocument(proposal["theme"])
if !ok {
return nil, errors.New("theme proposal not found")
}
validation := validateThemeProposal(theme)
validationDocument, _ := asDocument(run["validation"])
if validationDocument == nil {
validationDocument = Doc{}
}
validationDocument["theme"] = validation
status := "proposed"
if validation.Valid && allProposalValidationsPass(proposal, validationDocument) {
status = "ready_for_review"
}
updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{
"validation": validationDocument,
"status": status,
}, nil, identity.TenantID, true)
if err != nil {
return nil, err
}
return Doc{
"runId": runID,
"status": status,
"proposalRevision": updated["proposalRevision"],
"validation": validation,
"requiresCMSApproval": true,
}, nil
}
func (a *app) authoringReviewLink(r *http.Request, runID string) (Doc, error) {
run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
if anyToString(run["status"]) != "ready_for_review" {
return nil, errors.New("run must pass validation before review")
}
return Doc{
"runId": runID,
"status": "ready_for_review",
"reviewUrl": a.cfg.AppBaseURL + "/admin/authoring/runs/" + runID,
}, nil
}
func (a *app) discardAuthoringRun(r *http.Request, runID string) (Doc, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
if status := anyToString(run["status"]); status == "applied" || status == "published" {
return nil, errors.New("applied runs cannot be discarded")
}
updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{"status": "cancelled"}, nil, identity.TenantID, true)
if err != nil {
return nil, err
}
return Doc{"runId": runID, "status": updated["status"]}, nil
}
func (a *app) getOwnedAuthoringRun(r *http.Request, runID string) (Doc, error) {
run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
return run, err
}
func (a *app) getOwnedAuthoringRunWithIdentity(r *http.Request, runID string) (Doc, authoringIdentity, error) {
identity, err := a.resolveAuthoringIdentity(r)
if err != nil {
return nil, authoringIdentity{}, err
}
if a.db == nil {
return nil, authoringIdentity{}, errors.New("authoring store unavailable")
}
run, err := a.getByIDScoped("authoring_runs", runID, identity.TenantID, true)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, authoringIdentity{}, errors.New("authoring run not found")
}
return nil, authoringIdentity{}, errors.New("failed to load authoring run")
}
if anyToString(run["userId"]) != identity.UserID && !a.isAuthoringAdmin(r) {
return nil, authoringIdentity{}, errors.New("authoring run not found")
}
return run, identity, nil
}
func (a *app) resolveAuthoringIdentity(r *http.Request) (authoringIdentity, error) {
if a.cfg.AuthDisabled {
return authoringIdentity{UserID: "development-user", TenantID: "development"}, nil
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
tenant, _ := r.Context().Value(tenantContextKey).(tenantContext)
userID := resolveClaimsUserID(claims)
if userID == "" {
return authoringIdentity{}, errors.New("authentication required")
}
if strings.TrimSpace(tenant.ID) == "" {
return authoringIdentity{}, errors.New("tenant claim required")
}
return authoringIdentity{UserID: userID, TenantID: strings.TrimSpace(tenant.ID)}, nil
}
func (a *app) isAuthoringAdmin(r *http.Request) bool {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasAnyRole(claims, "admin", "platform_owner", "platform_admin")
}
func validateThemeProposal(theme Doc) themeValidationResult {
result := themeValidationResult{Valid: true, Errors: []string{}, Warnings: []string{}}
for _, field := range requiredThemeColors {
value := strings.TrimSpace(anyToString(theme[field]))
if !themeHexColorPattern.MatchString(value) {
result.Errors = append(result.Errors, field+" must be a six-digit hex color")
}
}
fontFamily := strings.TrimSpace(anyToString(theme["fontFamily"]))
if fontFamily == "" || len(fontFamily) > 200 {
result.Errors = append(result.Errors, "fontFamily is required and must be at most 200 characters")
}
radius, ok := numericValue(theme["radius"])
if !ok || radius < 0 || radius > 2 {
result.Errors = append(result.Errors, "radius must be between 0 and 2")
}
if len(result.Errors) == 0 {
for _, pair := range [][3]string{
{"foreground", "background", "foreground/background"},
{"primaryForeground", "primary", "primary foreground/primary"},
{"mutedForeground", "muted", "muted foreground/muted"},
} {
ratio := contrastRatio(anyToString(theme[pair[0]]), anyToString(theme[pair[1]]))
if ratio < 4.5 {
result.Errors = append(result.Errors, fmt.Sprintf("%s contrast %.2f is below 4.5", pair[2], ratio))
}
}
}
result.Valid = len(result.Errors) == 0
return result
}
func contrastRatio(first, second string) float64 {
firstLuminance := relativeLuminance(first)
secondLuminance := relativeLuminance(second)
lighter := math.Max(firstLuminance, secondLuminance)
darker := math.Min(firstLuminance, secondLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
func relativeLuminance(color string) float64 {
if !themeHexColorPattern.MatchString(color) {
return 0
}
channels := make([]float64, 0, 3)
for index := 1; index < 7; index += 2 {
value, _ := strconv.ParseInt(color[index:index+2], 16, 64)
channel := float64(value) / 255
if channel <= 0.04045 {
channel /= 12.92
} else {
channel = math.Pow((channel+0.055)/1.055, 2.4)
}
channels = append(channels, channel)
}
return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2]
}
func requiredArgumentString(arguments map[string]any, key string) (string, error) {
value := strings.TrimSpace(anyToString(arguments[key]))
if value == "" {
return "", fmt.Errorf("%s is required", key)
}
return value, nil
}
func asDocument(value any) (Doc, bool) {
switch typed := value.(type) {
case Doc:
return typed, true
case map[string]any:
return Doc(typed), true
default:
return nil, false
}
}
func int64Number(value any) int64 {
number, _ := numericValue(value)
return int64(number)
}
func numericValue(value any) (float64, bool) {
switch typed := value.(type) {
case int:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case float32:
return float64(typed), true
case float64:
return typed, true
default:
return 0, false
}
}