Files
member-console/internal/integrations/discourse/discourse.go
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

341 lines
15 KiB
Go

// Package discourse is the Discourse integration: forum access delivered as
// Discourse group membership, driven by the org-held `discourse_posting`
// boolean entitlement. It satisfies internal/integrations.Integration's
// mandatory capability (Slug, Provider, MigrationSource), internal/config.
// ConfigProvider's optional config capability, and internal/workflows.
// WorkflowProvider's optional workflows capability. Routes and UI assets
// arrive with the operator surface and webhook endpoint (this change's
// later task groups).
//
// Delivery model: the entitlement stays org-held (no core rewiring); a
// person's desired membership in a managed group is the OR-union across
// their active orgs' conferrals, read from materialized boolean entitlement
// state. A periodic sweep converges Discourse to desired state (level-
// triggered correctness); webhooks accelerate linking and drift correction.
// The person↔forum-user mapping (discourse.user_links) is provider-owned
// state, filled by the configured linkage mode — the reconciler is
// linkage-agnostic. See openspec/changes/discourse-integration/design.md.
//
// store is a separate Go package (sibling subpackage) for the same reason
// FedWiki's is: web (later tasks) imports store directly while this package
// imports web, so folding store into this package would close an import
// cycle. Callers alias the store package "dcmod", mirroring FedWiki's
// "fwmod" convention.
package discourse
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"net/http"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
dcclient "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/linkage"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
corework "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/spf13/viper"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/worker"
)
// templatesEmbed is Discourse's UI-asset tree (slug-prefixed template files;
// the integration ships no static assets). The go:embed directive must live
// in this directory — see the FedWiki adapter's identical arrangement.
//
//go:embed templates
var templatesEmbed embed.FS
// templatesFS roots the template directory so explicit filenames resolve
// directly. Panics on error: the embed directive guarantees the directory
// exists at compile time.
func templatesFS() fs.FS {
sub, err := fs.Sub(templatesEmbed, "templates")
if err != nil {
panic(fmt.Sprintf("discourse: sub templates FS: %v", err))
}
return sub
}
// New returns the Discourse integration adapter.
func New() Adapter {
return Adapter{}
}
// Adapter implements the Discourse integration's capability hooks,
// delegating to store (module) — and, in later task groups, workflows
// (Temporal reconciliation) and web (operator surface, webhook endpoint).
type Adapter struct{}
// Slug returns Discourse's provider-registry slug.
func (Adapter) Slug() string {
return dcmod.ModuleName
}
// Provider returns Discourse's provider-registry manifest source.
func (Adapter) Provider() integration.ProviderSource {
return dcmod.ProviderSource()
}
// MigrationSource returns Discourse's migration source.
func (Adapter) MigrationSource() db.MigrationSource {
return dcmod.MigrationSource()
}
// ConfigSpec declares Discourse's bootstrap configuration. discourse-base-url
// and discourse-api-key share the "Discourse" RequiredGroup — both are needed
// to call the admin API, so ValidateStart rejects a half-configured pair;
// leaving both empty is valid (integration dormant: registered, migrations
// applied, no provider calls). The remaining keys tune behavior and need no
// group: linkage mode selects how persons resolve to forum users;
// auto-create-users gates programmatic account creation (default: find-and-
// link only — creating forum accounts for members who never visited the
// forum is a per-deployment product decision); the webhook secret enables
// the webhook endpoint when set (declared here so it gets secret-file
// resolution; the endpoint itself arrives with a later task group).
// auto-create-users stays a string "true"/"false" enum rather than a bool:
// bool/duration flag constructors exist now (integration-config-parity),
// but Enum validation only covers strings, and the reject-garbage check is
// worth more here than a typed flag. The sync knobs are declared with their
// real types — their defaults must match what Startup's fallbacks assumed
// before declaration, so unset keys behave identically.
func (Adapter) ConfigSpec() []config.ConfigKey {
return []config.ConfigKey{
{Name: "discourse-base-url", RequiredGroup: "Discourse", Usage: "Base URL of the Discourse instance (e.g., https://forum.example.com)"},
{Name: "discourse-api-key", Secret: true, RequiredGroup: "Discourse", Usage: "Discourse admin API key (global key; used with discourse-api-username)"},
{Name: "discourse-api-username", Default: "system", Usage: "Api-Username header value for Discourse admin API calls"},
{Name: "discourse-linkage-mode", Default: "oidc", Enum: []string{"email", "oidc", "discourseconnect"}, Usage: "How persons are matched to Discourse users: email (admin lookup by verified primary email), oidc (by-external lookup via the bundled OpenID Connect plugin), or discourseconnect (by-external lookup via DiscourseConnect SSO)"},
{Name: "discourse-auto-create-users", Default: "false", Enum: []string{"false", "true"}, Usage: "Create Discourse accounts for entitled persons with no forum user (true/false); the default only finds and links existing users"},
{Name: "discourse-webhook-secret", Secret: true, Usage: "Shared secret for verifying Discourse webhook signatures; leave empty to disable the webhook endpoint"},
{Name: "discourse-sync-interval", Default: workflows.DefaultSyncInterval, Usage: "Interval between Discourse group-sync sweeps"},
{Name: "discourse-sync-trigger-immediately", Default: false, Usage: "Run a Discourse group-sync sweep immediately on startup"},
}
}
// configured reports whether the required Discourse config group is present.
// ValidateStart already guarantees all-or-nothing on the group, so checking
// one member suffices.
func configured() bool {
return viper.GetString("discourse-base-url") != ""
}
// apiClient constructs the rate-limited Discourse client from this
// integration's viper-declared config (its own concern, not threaded
// through the generic hook parameters — FedWiki's convention).
func apiClient(logger *slog.Logger) *dcclient.Client {
return dcclient.New(dcclient.Config{
BaseURL: viper.GetString("discourse-base-url"),
APIKey: viper.GetString("discourse-api-key"),
APIUsername: viper.GetString("discourse-api-username"),
Logger: logger,
})
}
// linker constructs the linkage engine under the configured mode. An
// invalid mode is logged and falls back to the default (oidc) rather than
// halting boot; this is defense in depth only — ValidateStart now rejects
// a discourse-linkage-mode value outside its declared Enum at boot (design
// D8, schema-hardening), so this fallback should be unreachable in
// practice.
func linker(logger *slog.Logger, c *dcclient.Client) *linkage.Linker {
mode, err := linkage.ParseMode(viper.GetString("discourse-linkage-mode"))
if err != nil {
logger.Error("invalid discourse-linkage-mode; falling back to oidc", slog.Any("error", err))
mode = linkage.ModeOIDC
}
return &linkage.Linker{
Mode: mode,
AutoCreate: viper.GetBool("discourse-auto-create-users"),
Client: c,
Logger: logger,
}
}
// Templates returns Discourse's template directory for composition into the
// server's shared template set; every file and defined name is prefixed
// "discourse_" per the UIProvider namespacing rule.
func (Adapter) Templates() fs.FS {
return templatesFS()
}
// Static returns nil: the integration ships no static assets (a nil FS is a
// documented UIProvider no-op).
func (Adapter) Static() fs.FS {
return nil
}
// RegisterRoutes mounts the operator surface and, when configured, the
// mutation partials and webhook endpoint. The operator page mounts even
// when the integration is dormant — the registry-driven nav links to it,
// and the page itself states the unconfigured state (spec: "Unconfigured
// integration") — while mutation routes and the webhook need a reachable
// forum and so mount only when configured. deps supplies the DB and
// Temporal client; Discourse-specific config is read from viper (this
// integration's own concern).
func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
var c *dcclient.Client
if configured() {
c = apiClient(deps.Logger)
}
operatorPath := dcmod.ProviderSource().ProviderManifest().OperatorSurfacePath
operatorHandler, err := web.NewDiscourseOperatorHandler(web.DiscourseOperatorHandlerConfig{
DB: deps.Database,
Logger: deps.Logger,
AuthConfig: deps.AuthConfig,
Client: c,
Temporal: deps.TemporalClient,
Configured: configured(),
TemplatesFS: templatesFS(),
SweepScheduleID: workflows.SyncScheduleID,
})
if err != nil {
return err
}
mux.HandleFunc("GET "+operatorPath, server.RequireOperatorRole(deps.AuthConfig, deps.Logger, operatorHandler.GetPage))
if configured() {
mux.HandleFunc("POST /partials/operator/discourse/mappings",
server.RequireOperatorRole(deps.AuthConfig, deps.Logger, operatorHandler.CreateMapping))
mux.HandleFunc("DELETE /partials/operator/discourse/mappings/{mappingID}",
server.RequireOperatorRole(deps.AuthConfig, deps.Logger, operatorHandler.DeleteMapping))
}
if !configured() {
return nil
}
mode, err := linkage.ParseMode(viper.GetString("discourse-linkage-mode"))
if err != nil {
mode = linkage.ModeOIDC
}
// Member dashboard card body (mounted only when configured, matching
// the card declaration in DashboardCards below). Session-authenticated
// inside the handler like FedWiki's member partials; resource keys come
// from the manifest declaration, not a literal here.
memberHandler, err := web.NewMemberForumHandler(web.MemberForumHandlerConfig{
DB: deps.Database,
Logger: deps.Logger,
AuthConfig: deps.AuthConfig,
ForumURL: viper.GetString("discourse-base-url"),
Mode: mode,
ResourceKeys: dcmod.ProviderSource().ProviderManifest().ResourceKeys,
TemplatesFS: templatesFS(),
})
if err != nil {
return err
}
mux.HandleFunc("GET /partials/discourse/forum-access", memberHandler.GetForumAccess)
if viper.GetString("discourse-webhook-secret") != "" {
temporalClient := deps.TemporalClient
webhookHandler := web.NewWebhookHandler(web.WebhookHandlerConfig{
DB: deps.Database,
Logger: deps.Logger,
Secret: viper.GetString("discourse-webhook-secret"),
Mode: mode,
Client: c,
StartReconcile: func(personID string) error {
_, err := temporalClient.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
ID: workflows.ReconcilePersonWorkflowID(personID),
TaskQueue: queues.Main,
}, workflows.ReconcilePersonWorkflow, personID)
if err != nil && temporal.IsWorkflowExecutionAlreadyStartedError(err) {
// A reconcile for this person is already in flight —
// per-person serialization working as intended; the
// sweep covers any change the in-flight run misses.
return nil
}
return err
},
})
webhookHandler.RegisterRoutes(mux)
}
return nil
}
// DashboardCards declares Discourse's member dashboard card (server.
// DashboardCardProvider): the forum access/link-state card served by the
// member partial mounted in RegisterRoutes above. Declared only when the
// integration is configured — a dormant integration mounts no member
// partial route, so a card would 404; per-MEMBER state (access granted or
// not) is expressed inside the rendered partial, never by hiding the card.
// No refresh event (state changes arrive via sweep/webhook, not member
// action on this page) and no scripts.
func (Adapter) DashboardCards() []server.DashboardCard {
if !configured() {
return nil
}
return []server.DashboardCard{{
Title: "Community Forum",
PartialPath: "/partials/discourse/forum-access",
}}
}
// CSRFExemptPaths declares the webhook path exempt only when the webhook
// route is actually mounted (Stripe's convention): the endpoint
// authenticates via the provider's HMAC signature, not a CSRF token.
func (Adapter) CSRFExemptPaths() []string {
if configured() && viper.GetString("discourse-webhook-secret") != "" {
return []string{web.WebhookPath}
}
return nil
}
// RegisterWorkflows registers Discourse's Temporal workflows and activities
// against the shared worker. Registration is unconditional (workflow
// definitions are inert without executions); whether any execution is
// scheduled is Startup's concern.
func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog.Logger) {
w.RegisterWorkflow(workflows.DiscourseGroupSyncWorkflow)
w.RegisterWorkflow(workflows.ReconcilePersonWorkflow)
c := apiClient(logger)
w.RegisterActivity(workflows.NewActivities(workflows.ActivitiesConfig{
Database: database,
Logger: logger,
Client: c,
Linker: linker(logger, c),
}))
}
// Startup ensures the group-sync sweep schedule when the integration is
// configured. When the Discourse config group is absent the integration is
// dormant: registered, migrations applied, no provider calls — logged so an
// operator can tell dormancy from misconfiguration. Non-fatal by convention:
// failures here degrade this integration, never halt boot. The sweep
// cadence is tunable via discourse-sync-interval (declared in ConfigSpec
// with its default; read from viper here because it configures this hook's
// own behavior).
func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error {
if !configured() {
logger.Info("discourse integration unconfigured; skipping startup (set discourse-base-url and discourse-api-key to enable)")
// Schedules are durable: one ensured by an earlier configured boot
// would keep firing sweeps into this unconfigured worker forever.
if err := corework.PauseLeftoverSchedule(ctx, c, workflows.SyncScheduleID, logger); err != nil {
logger.Error("failed to pause leftover discourse group-sync schedule", slog.Any("error", err))
}
return nil
}
scheduleManager := workflows.NewScheduleManager(c, logger)
if err := scheduleManager.EnsureSyncSchedule(ctx, workflows.ScheduleConfig{
Interval: viper.GetDuration("discourse-sync-interval"),
TriggerImmediately: viper.GetBool("discourse-sync-trigger-immediately"),
}); err != nil {
logger.Error("failed to set up discourse group-sync schedule", slog.Any("error", err))
} else if err := corework.ResumeDormantSchedule(ctx, c, workflows.SyncScheduleID, logger); err != nil {
logger.Error("failed to resume discourse group-sync schedule", slog.Any("error", err))
}
return nil
}