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.
341 lines
15 KiB
Go
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
|
|
}
|