- Restructure operator sidebar into a flat task list with indented children; fold plan topology into plan ladders - Expand member catalog non-plan section to all published non-tier products; require recurring Stripe-mapped prices for purchase - Add operator domains placements and terminal-claims ledger; redirect /domains to the FedWiki Sites Domains anchor - Apply canonical vocabulary and chrome/form conventions; migrate seeded FedWiki Sites display name
488 lines
17 KiB
Go
488 lines
17 KiB
Go
// Package web is the FedWiki integration's HTTP surface: the member-facing
|
|
// JSON API (this file), the HTMX partials backing the member dashboard's
|
|
// site widget (partials.go), and the read-only operator page (operator.go).
|
|
// Moved here from internal/server (openspec/changes/integration-extraction
|
|
// task 2.3) so internal/server carries zero FedWiki-specific handler code;
|
|
// internal/integrations/fedwiki.Adapter constructs these handlers from the
|
|
// outside via its RouteProvider hook (RegisterRoutes), exactly as internal/
|
|
// server itself used to construct them directly.
|
|
//
|
|
// This package imports internal/server for shared HTTP-layer plumbing
|
|
// (Deps, SafeTemplates, OperatorPageData/RequireOperatorRole) and
|
|
// internal/dnsname for the shared DNS shape rules — the allowed import
|
|
// direction (integration trees may import core packages; internal/server
|
|
// must never import this package back).
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
|
|
"github.com/google/uuid"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// FedWikiHandler handles FedWiki site management API endpoints.
|
|
type FedWikiHandler struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
// Registry is the domains registry: the authority the external-domain
|
|
// branch of CreateSite consults before it initiates or creates anything.
|
|
Registry *domains.Registry
|
|
Database *sql.DB
|
|
Logger *slog.Logger
|
|
TemporalClient client.Client
|
|
AuthConfig *auth.Config
|
|
FedWikiAllowedDomains []string // Domains where users can create sites
|
|
FedWikiSiteScheme string // http or https
|
|
CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow
|
|
// CustomDomainsGate is the shared external-claim gate
|
|
// (centralize-external-claim-gate); affordance rendering only — the
|
|
// registry enforces the same gate at ClaimExternal.
|
|
CustomDomainsGate domains.ExternalClaimGate
|
|
SupportURL string
|
|
}
|
|
|
|
// FedWikiHandlerConfig holds configuration for creating a FedWikiHandler.
|
|
type FedWikiHandlerConfig struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
Registry *domains.Registry
|
|
Database *sql.DB
|
|
Logger *slog.Logger
|
|
TemporalClient client.Client
|
|
AuthConfig *auth.Config
|
|
FedWikiAllowedDomains []string
|
|
FedWikiSiteScheme string
|
|
CustomDomainTarget string
|
|
CustomDomainsGate domains.ExternalClaimGate
|
|
SupportURL string
|
|
}
|
|
|
|
// NewFedWikiHandler creates a new FedWikiHandler.
|
|
func NewFedWikiHandler(cfg FedWikiHandlerConfig) *FedWikiHandler {
|
|
return &FedWikiHandler{
|
|
SiteQ: cfg.SiteQ,
|
|
EntitlementsQ: cfg.EntitlementsQ,
|
|
Registry: cfg.Registry,
|
|
Database: cfg.Database,
|
|
Logger: cfg.Logger,
|
|
TemporalClient: cfg.TemporalClient,
|
|
AuthConfig: cfg.AuthConfig,
|
|
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
|
|
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
|
|
CustomDomainTarget: cfg.CustomDomainTarget,
|
|
CustomDomainsGate: cfg.CustomDomainsGate,
|
|
SupportURL: cfg.SupportURL,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers all FedWiki API routes.
|
|
func (h *FedWikiHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/fedwiki/sites", h.ListSites)
|
|
mux.HandleFunc("POST /api/fedwiki/sites", h.CreateSite)
|
|
mux.HandleFunc("DELETE /api/fedwiki/sites/{domain}", h.DeleteSite)
|
|
mux.HandleFunc("GET /api/fedwiki/quota", h.GetQuota)
|
|
}
|
|
|
|
// getSessionFromRequest extracts the user's session.
|
|
func (h *FedWikiHandler) getSessionFromRequest(r *http.Request) (*auth.UserSession, error) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
return nil, http.ErrNoCookie
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// respondJSON writes a JSON response.
|
|
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if data != nil {
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
}
|
|
|
|
// respondError writes a JSON error response.
|
|
func respondError(w http.ResponseWriter, status int, message string, supportURL string) {
|
|
resp := map[string]interface{}{
|
|
"error": message,
|
|
"success": false,
|
|
}
|
|
if supportURL != "" {
|
|
resp["supportURL"] = supportURL
|
|
}
|
|
respondJSON(w, status, resp)
|
|
}
|
|
|
|
// ListSitesResponse is the response for listing sites.
|
|
type ListSitesResponse struct {
|
|
Success bool `json:"success"`
|
|
Sites []SiteDTO `json:"sites"`
|
|
}
|
|
|
|
// SiteDTO is the data transfer object for a site.
|
|
type SiteDTO struct {
|
|
ID string `json:"id"`
|
|
Domain string `json:"domain"`
|
|
IsCustomDomain bool `json:"isCustomDomain"`
|
|
CreatedAt string `json:"createdAt"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// ListSites handles GET /api/fedwiki/sites - returns user's sites.
|
|
func (h *FedWikiHandler) ListSites(w http.ResponseWriter, r *http.Request) {
|
|
session, err := h.getSessionFromRequest(r)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get session", slog.Any("error", err))
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
|
return
|
|
}
|
|
|
|
workspaceID := session.WorkspaceID
|
|
sites, err := h.SiteQ.ListSitesByWorkspace(r.Context(), workspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get user sites", slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "Failed to retrieve sites", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
siteDTOs := make([]SiteDTO, len(sites))
|
|
for i, site := range sites {
|
|
siteDTOs[i] = SiteDTO{
|
|
ID: site.SiteID,
|
|
Domain: site.Domain,
|
|
IsCustomDomain: site.IsCustomDomain,
|
|
CreatedAt: site.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
URL: h.buildSiteURL(site.Domain, site.IsCustomDomain),
|
|
}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, ListSitesResponse{
|
|
Success: true,
|
|
Sites: siteDTOs,
|
|
})
|
|
}
|
|
|
|
// buildSiteURL constructs the full URL for a site.
|
|
func (h *FedWikiHandler) buildSiteURL(domain string, isCustomDomain bool) string {
|
|
scheme := h.FedWikiSiteScheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
return scheme + "://" + domain
|
|
}
|
|
|
|
// CreateSiteRequest is the request body for creating a site.
|
|
type CreateSiteRequest struct {
|
|
Domain string `json:"domain"`
|
|
IsCustomDomain bool `json:"isCustomDomain"`
|
|
}
|
|
|
|
// CreateSiteResponse is the response for creating a site.
|
|
type CreateSiteResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
WorkflowID string `json:"workflowId,omitempty"`
|
|
}
|
|
|
|
// CreateSite handles POST /api/fedwiki/sites - initiates site creation workflow.
|
|
func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
|
|
session, err := h.getSessionFromRequest(r)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get session", slog.Any("error", err))
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
|
return
|
|
}
|
|
|
|
var req CreateSiteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid request body", "")
|
|
return
|
|
}
|
|
|
|
// Normalize before anything reads the name: the registry stores and
|
|
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
|
|
// must lose its root label here or the row and the farm call disagree.
|
|
req.Domain = dnsname.Normalize(req.Domain)
|
|
if req.Domain == "" {
|
|
respondError(w, http.StatusBadRequest, "Domain is required", "")
|
|
return
|
|
}
|
|
|
|
// External-domain branch: the registry's own four-branch disposition
|
|
// (design D4, and the fedwiki-sites delta's "Site creation allocates its
|
|
// name through the domains registry"):
|
|
//
|
|
// unclaimed → 202 claim initiation (wire shape unchanged);
|
|
// own pending → 400 "verification in progress", the ONE refusal
|
|
// that names its reason — the caller already knows;
|
|
// own active claim → fall through to the ordinary create path, which
|
|
// now succeeds instantly (no verification, no site
|
|
// created by the verification workflow);
|
|
// anything else → 400 generic "unavailable". Another workspace's
|
|
// live claim is indistinguishable from a policy
|
|
// refusal here, closing the oracle the pre-registry
|
|
// code left open ("that domain already has a
|
|
// verification in progress" for ANY holder).
|
|
//
|
|
// Classification comes FIRST, and the entitlement gates the unclaimed
|
|
// branch alone: the boolean buys the right to claim a NEW external
|
|
// domain, not the right to use one already claimed. A name under a claim
|
|
// the workspace already holds places instantly whatever the plan says
|
|
// ("Names under a verified claim place instantly"), so a revoked
|
|
// entitlement must not strand a live claim.
|
|
if req.IsCustomDomain {
|
|
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, req.Domain)
|
|
if err != nil {
|
|
h.Logger.Error("domain availability check failed", slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "We couldn't check that domain right now. Please try again.", h.SupportURL)
|
|
return
|
|
}
|
|
switch disposition {
|
|
case dispositionRefused, dispositionOwnPending:
|
|
respondError(w, http.StatusBadRequest, userMsg, h.SupportURL)
|
|
return
|
|
case dispositionUnclaimed:
|
|
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "Failed to check your plan. Please try again.", h.SupportURL)
|
|
return
|
|
}
|
|
if !enabled {
|
|
respondError(w, http.StatusForbidden, "Your current plan doesn't include custom domains.", h.SupportURL)
|
|
return
|
|
}
|
|
initiation, msg := startClaimVerification(r.Context(), h.Registry, h.TemporalClient, h.Logger,
|
|
session.WorkspaceID, req.Domain, h.CustomDomainTarget)
|
|
if msg != "" {
|
|
respondError(w, http.StatusBadRequest, msg, h.SupportURL)
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusAccepted, CustomDomainInitiationResponse{
|
|
Success: true,
|
|
Message: "Domain verification initiated. Publish the DNS records to continue; once the domain is verified, creating the site is instant.",
|
|
VerificationID: initiation.ClaimID,
|
|
Domain: initiation.Domain,
|
|
TXTRecordName: initiation.TXTRecordName,
|
|
TXTRecordValue: initiation.TXTRecordValue,
|
|
ConnectTarget: initiation.ConnectTarget,
|
|
})
|
|
return
|
|
}
|
|
// dispositionOwnActive falls through to the create path below.
|
|
} else if errMsg := dnsname.ValidateDNSLabel(req.Domain); errMsg != "" {
|
|
// Hosted path only: a custom domain is a full FQDN, not a label.
|
|
respondError(w, http.StatusBadRequest, errMsg, "")
|
|
return
|
|
}
|
|
|
|
// Check if Temporal client is available
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
respondError(w, http.StatusServiceUnavailable, "Site creation service is not available", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
// Start the workflow
|
|
workflowID := "create-fedwiki-site-" + uuid.New().String()
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
|
|
input := workflows.CreateFedWikiSiteWorkflowInput{
|
|
WorkspaceID: session.WorkspaceID,
|
|
Domain: req.Domain,
|
|
OwnerName: session.Username,
|
|
OwnerID: session.OIDCSubject,
|
|
IsCustomDomain: req.IsCustomDomain,
|
|
SupportURL: h.SupportURL,
|
|
}
|
|
|
|
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.CreateFedWikiSiteWorkflow, input)
|
|
if err != nil {
|
|
h.Logger.Error("failed to start workflow",
|
|
slog.String("workflowID", workflowID),
|
|
slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
h.Logger.Info("site creation workflow started",
|
|
slog.String("workflowID", we.GetID()),
|
|
slog.String("runID", we.GetRunID()),
|
|
slog.String("domain", req.Domain))
|
|
|
|
respondJSON(w, http.StatusAccepted, CreateSiteResponse{
|
|
Success: true,
|
|
Message: "Site creation initiated. This may take a few minutes.",
|
|
WorkflowID: we.GetID(),
|
|
})
|
|
}
|
|
|
|
// DeleteSite handles DELETE /api/fedwiki/sites/{domain} - initiates site deletion workflow.
|
|
func (h *FedWikiHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
|
|
session, err := h.getSessionFromRequest(r)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get session", slog.Any("error", err))
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
|
return
|
|
}
|
|
|
|
domain := r.PathValue("domain")
|
|
if domain == "" {
|
|
respondError(w, http.StatusBadRequest, "Domain is required", "")
|
|
return
|
|
}
|
|
|
|
// Verify the site belongs to the user's workspace
|
|
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get site", slog.Any("error", err))
|
|
respondError(w, http.StatusNotFound, "Site not found", "")
|
|
return
|
|
}
|
|
|
|
if site.WorkspaceID != session.WorkspaceID {
|
|
respondError(w, http.StatusForbidden, "You do not own this site", "")
|
|
return
|
|
}
|
|
|
|
// Check if Temporal client is available
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
respondError(w, http.StatusServiceUnavailable, "Site creation service is not available", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
// Start the workflow
|
|
workflowID := "delete-fedwiki-site-" + uuid.New().String()
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
|
|
input := workflows.DeleteFedWikiSiteWorkflowInput{
|
|
Domain: domain,
|
|
WorkspaceID: session.WorkspaceID,
|
|
WasActive: site.Status == "active",
|
|
SupportURL: h.SupportURL,
|
|
}
|
|
|
|
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.DeleteFedWikiSiteWorkflow, input)
|
|
if err != nil {
|
|
h.Logger.Error("failed to start workflow",
|
|
slog.String("workflowID", workflowID),
|
|
slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
h.Logger.Info("site deletion workflow started",
|
|
slog.String("workflowID", we.GetID()),
|
|
slog.String("runID", we.GetRunID()),
|
|
slog.String("domain", domain))
|
|
|
|
respondJSON(w, http.StatusAccepted, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Site deletion initiated. This may take a few minutes.",
|
|
"workflowId": we.GetID(),
|
|
})
|
|
}
|
|
|
|
// QuotaResponse is the response for getting quota information.
|
|
type QuotaResponse struct {
|
|
Success bool `json:"success"`
|
|
CurrentCount int64 `json:"currentCount"`
|
|
Quota int64 `json:"quota"`
|
|
CanCreate bool `json:"canCreate"`
|
|
}
|
|
|
|
// GetQuota handles GET /api/fedwiki/quota - returns user's quota information.
|
|
func (h *FedWikiHandler) GetQuota(w http.ResponseWriter, r *http.Request) {
|
|
session, err := h.getSessionFromRequest(r)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get session", slog.Any("error", err))
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
|
return
|
|
}
|
|
|
|
workspaceID := session.WorkspaceID
|
|
|
|
// Get site count
|
|
count, err := h.SiteQ.CountSitesByWorkspace(r.Context(), workspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get site count", slog.Any("error", err))
|
|
respondError(w, http.StatusInternalServerError, "Failed to retrieve quota information", h.SupportURL)
|
|
return
|
|
}
|
|
|
|
// Get entitlement usage to determine quota
|
|
usage, err := h.getWorkspaceQuota(r.Context(), workspaceID)
|
|
if err != nil {
|
|
// No entitlement means no quota
|
|
respondJSON(w, http.StatusOK, QuotaResponse{
|
|
Success: true,
|
|
CurrentCount: count,
|
|
Quota: 0,
|
|
CanCreate: false,
|
|
})
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, QuotaResponse{
|
|
Success: true,
|
|
CurrentCount: usage.currentUsage,
|
|
Quota: usage.resourceLimit,
|
|
CanCreate: usage.currentUsage < usage.resourceLimit,
|
|
})
|
|
}
|
|
|
|
type workspaceQuota struct {
|
|
currentUsage int64
|
|
resourceLimit int64
|
|
}
|
|
|
|
func (h *FedWikiHandler) getWorkspaceQuota(ctx context.Context, workspaceID string) (*workspaceQuota, error) {
|
|
// Get the workspace's primary pool assignment
|
|
assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get usage for sites on this pool
|
|
usage, err := h.EntitlementsQ.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
|
|
PoolID: assignment.PoolID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get the entitlement limit
|
|
ent, err := h.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: assignment.PoolID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &workspaceQuota{
|
|
currentUsage: usage.CurrentUsage,
|
|
resourceLimit: ent.ResourceLimit,
|
|
}, nil
|
|
}
|