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 } }