Documentation
¶
Overview ¶
Package core is the Phase-0 scaffolding for the indexer-site plugin architecture described in PLUGIN-ARCHITECTURE.md at the repository root.
At this point in the codebase the package is INERT: it defines the Plugin interface, the Core mediator struct, the global registry, the topo-sort + migration runner, and a tiny /admin/plugins admin page. It does NOT — and must not, in this phase — move any existing storage, handler, service, or model code. The mediator is a FACADE over what already exists; every concrete adapter is constructed in cmd/main.go from the live services and injected via core.Boot.
Phase 0 success criteria (see § 13 of the design doc):
- With zero plugins registered, the binary boots, serves every existing route, and passes every existing test exactly as it does today.
- cmd/main.go has a single new call (core.Boot) near the end of its wiring. The rest of main.go is untouched.
- A new core.plugin_migrations table is created by the numbered migration runner; the runner inside this package never writes to it until a plugin is registered.
Future phases (Phase 1+) will extract the first real plugin (wiki — see § 12 of the design doc), at which point this package becomes load-bearing rather than ornamental.
CALLER CONTRACT:
- Plugins are registered via RegisterPlugin from an init() function in plugins/<name>/plugin.go. The blank-import in cmd/main.go is the manifest of what's compiled into a given binary — Caddy/xcaddy style.
- cmd/main.go calls Boot exactly once, after the legacy wiring is complete and before HTTP serving begins.
- All inter-plugin coupling flows through Core. Plugins MUST NOT import each other's packages except through interfaces defined on Core or on a peer plugin's exported root API.
Index ¶
- Constants
- Variables
- func AdminHandler(rt *Runtime, c *Core) gin.HandlerFunc
- func CSRFFromRequest(gc *gin.Context) string
- func CSRFToken(c *Core, gc *gin.Context, legacyKeys ...string) string
- func DefaultLogger() *slog.Logger
- func FeatureOn(c *Core, key string) bool
- func IsPublicProfile(c *gin.Context) bool
- func RegisterPlugin(name string, factory Factory)
- func RegisteredNames() []string
- func RunPluginMigrations(ctx context.Context, db *sqlx.DB) error
- func SetPublicProfile(c *gin.Context)
- func SetViewSubject(c *gin.Context, userID int64)
- func SetWidgetConfig(c *gin.Context, cfg string)
- func SetWidgetItem(c *gin.Context, kind string, id int64)
- func SiteWritable(ctx context.Context, c *Core) bool
- func ViewSubject(c *gin.Context) (int64, bool)
- func WidgetConfig(c *gin.Context) string
- type Access
- type AuthAdapter
- type AuthService
- type CSRFTokenFunc
- type ConfigService
- type Core
- func (c *Core) Access() Access
- func (c *Core) AllViews(slot ViewSlot) []View
- func (c *Core) AllWidgets() []Widget
- func (c *Core) DeclareEvent(def EventDef) error
- func (c *Core) Emit(ctx context.Context, e Event)
- func (c *Core) EventDefs() []EventDef
- func (c *Core) EventSubscribers(name string) []string
- func (c *Core) ExtensionDefinition(name string) (ExtensionDef, bool)
- func (c *Core) ExtensionNames() []string
- func (c *Core) FeatureByKey(key string) (Feature, bool)
- func (c *Core) Features() []Feature
- func (c *Core) LoggerFor(plugin string) *slog.Logger
- func (c *Core) Lookup(name string) (any, bool)
- func (c *Core) MissingExtensions() map[string]int
- func (c *Core) On(name, owner string, fn EventHandler)
- func (c *Core) Register(name string, svc any) error
- func (c *Core) RegisterDef(def ExtensionDef, svc any) error
- func (c *Core) RegisterFeature(f Feature) error
- func (c *Core) RegisterView(v View) error
- func (c *Core) RegisterWidget(w Widget) error
- func (c *Core) SetAccess(a Access) error
- func (c *Core) SubscribedEventNames() []string
- func (c *Core) Views(slot ViewSlot) []View
- func (c *Core) WidgetBySlug(slug string) (Widget, bool)
- func (c *Core) Widgets() []Widget
- type Deps
- type EntitlementGrant
- type EntitlementStore
- type EntitlementsConfig
- type EntitlementsService
- type ErrorAdapter
- type ErrorReporter
- type Event
- type EventDef
- type EventHandler
- type EventKind
- type ExtKind
- type ExtensionDef
- type Factory
- type Feature
- type FeatureService
- type HTTPClientService
- type Job
- type LedgerEntry
- type MemEntitlementStore
- func (m *MemEntitlementStore) DeleteGrant(_ context.Context, userID int64, key, source string) error
- func (m *MemEntitlementStore) GrantsFor(_ context.Context, userID int64) ([]EntitlementGrant, error)
- func (m *MemEntitlementStore) SetClock(fn func() time.Time)
- func (m *MemEntitlementStore) UpsertGrant(_ context.Context, userID int64, g EntitlementGrant) error
- type Metadata
- type NavHint
- type Notification
- type NotificationsAdapter
- type NotificationsService
- type Plugin
- type PointsAdapter
- type PointsService
- type RBACService
- type RedisService
- type RegistrationMode
- type Role
- type RouterAdapter
- type RouterService
- type Runtime
- type SchedulerAdapter
- type SchedulerService
- type SchemaDB
- type SiteMode
- type SiteStateService
- type StorageService
- type User
- type UsersAdapter
- type UsersService
- type View
- type ViewSlot
- type ViewingMode
- type Widget
- type WidgetItemRef
Constants ¶
const ( CSRFContextKey = "csrf_token" CSRFFieldName = "_csrf" )
CSRFContextKey is where a host's middleware puts the token for the request being served, and CSRFFieldName is the form field it must be submitted in.
THE SECOND WAY IN, and it exists because the first one needs a *Core. A double-submit middleware already sets this so its own templates can read it; a renderer that has the gin context but was never handed the Core — every core.View in loon-baseline, and schedule's bundled config page — can get the token from here without its constructor growing a parameter.
Prefer the registry when you have a *Core: it is explicit about who published what. Use this when you do not.
const ( FlavourIndexer = "indexer" FlavourTracker = "tracker" // FlavourAny says "either half, I do not care" — a forum, a shop, a // points ledger. // // It exists so that ANSWER is sayable. An empty Flavours also runs // everywhere and always will, because out-of-tree plugins compiled // against an older core have no field to fill in — but that makes // absence mean two things at once, "I belong to both" and "nobody has // thought about it", and those want telling apart. A plugin that has // decided says so; anything still empty is a plugin nobody has asked. FlavourAny = "any" )
The halves a site can have. A deployment is one, the other, or both, and Core.Flavours carries the set that is ON — which is why "both" is not a third constant here: it is the two-element set, and treating it as a value of its own is how every caller grows a three-way switch.
const CSRFTokenName = "csrf.token"
CSRFTokenName is where a host publishes its per-request token minter, as CSRFTokenFunc. Registered BEFORE Boot, like every seam a plugin resolves at Provision.
const DefaultEntitlementsTTL = 5 * time.Minute
DefaultEntitlementsTTL matches the host's historical UserLimitsCache window: writes through Grant/Revoke invalidate immediately in-process, so the TTL only bounds staleness for expiry lapses and writes from OTHER processes (split web/worker deployments share the table, not the cache).
NavHidden hides a view from nav menus without unmounting it.
Variables ¶
var ErrEntitlementsNotWired = errors.New("core: EntitlementsService not wired — set EntitlementsConfig.Store")
ErrEntitlementsNotWired indicates the entitlements subsystem has no backing store. Returned by Grant/Revoke when EntitlementsConfig.Store was nil — loud, like ErrPointsNotWired, so a mis-wired host fails at the first write instead of silently dropping grants. Reads on an unwired service are fully inert: Has false, Limit def, and neither RoleOf nor the Baseline is consulted — a store-less service must not answer access questions from half its sources or hit the users table uncached on every read.
var ErrInsufficientPoints = errors.New("core: insufficient points")
ErrInsufficientPoints is returned by Deduct when the user's balance would go negative. Plugins should compare against this sentinel with errors.Is.
var ErrPointsNotWired = errors.New("core: PointsService not wired — call NewPoints with a non-nil adapter")
ErrPointsNotWired indicates the points subsystem was not configured at boot. Returned by every method on the pointsAdapter when the corresponding callback is nil — see the doc on PointsAdapter for why this is loud rather than a no-op.
Functions ¶
func AdminHandler ¶
func AdminHandler(rt *Runtime, c *Core) gin.HandlerFunc
AdminHandler renders the /admin/plugins overview. Wired in cmd/main.go alongside the rest of the admin handlers:
admin.GET("/plugins", core.AdminHandler(runtime, coreMediator))
The handler intentionally stays inside pkg/core (rather than living under web/handlers/) because every other admin handler imports the host's session-cookie + role-gate stack, and putting this one there would require pkg/core to import web/handlers — the very coupling Phase 0 is trying to avoid.
At Phase 0 the page lists zero plugins ("No plugins registered."). At Phase 1 it lists every registered plugin with its declared metadata + applied migration count pulled from core.plugin_migrations.
func CSRFFromRequest ¶
CSRFFromRequest reads the token a host's middleware left on this request, empty when there is none.
Empty is not an error, for the reason CSRFToken's comment gives: a host with no CSRF middleware is legitimate. Render the field anyway.
func CSRFToken ¶
CSRFToken resolves the token for this request, empty when no host published one.
EMPTY IS DELIBERATELY NOT AN ERROR: a host with no CSRF middleware is a legitimate host, and its forms work with an empty hidden field. What must never happen is the field being ABSENT, which is why every caller puts the result in its view model unconditionally rather than gating the markup on it. A missing field is a 403 the person clicking cannot diagnose; an empty one on a host with no middleware is ignored.
legacyKeys are tried after the shared name, so a host that wired a plugin-specific key before this existed keeps working.
func DefaultLogger ¶
DefaultLogger returns a slog.Logger writing JSON to stderr at info level. cmd/main.go uses this when no logger is configured — production callers should swap in a logger tied to the existing logging pipeline (or to the Discord error webhook) before passing the Core to plugins.
Kept here rather than in core.go so a future change to the logging backend (e.g. add OTLP) only touches this file.
func FeatureOn ¶
FeatureOn is the question every caller actually has.
The order is: what the host decided, then what the plugin declared, then on. An empty key is on — a View or Widget that names no feature is not gated by one, and that is the common case by a distance.
func IsPublicProfile ¶
IsPublicProfile reports whether this render is the public profile. Plugins whose widget is a control rather than a fact return empty when it is true.
func RegisterPlugin ¶
RegisterPlugin captures a factory for the named plugin. Panics on duplicate registration — there is exactly one provider per name in a given binary, and a duplicate is a programmer error that should be caught at init() rather than quietly winning by import order.
func RegisteredNames ¶
func RegisteredNames() []string
RegisteredNames returns a sorted snapshot of registered plugin names. Useful for the /admin/plugins overview and for boot-time logging. Safe to call concurrently.
func SetPublicProfile ¶
SetPublicProfile marks this render as the public profile. Hosts set it on the /u/<username> path and nowhere else.
It exists because a SlotUserWidget renders on BOTH profile pages, and the self-only widgets among them -- an agent fleet roster, an IRC verification token, anything carrying a control rather than a fact -- are correct on the settings page and wrong on the public one. Their existing viewer==subject check cannot tell the two apart: the owner passes it on both.
Nothing here is a security boundary. The viewer==subject check is what stops a stranger seeing those cards, and it still does. This only stops the OWNER being shown their own private controls on the page whose purpose is to show them what everybody else sees.
func SetViewSubject ¶
SetViewSubject stores the profile-owner's user id before rendering user.* views. Hosts call this in their profile handler.
func SetWidgetConfig ¶
SetWidgetConfig records the setting stored for the placement being rendered. Hosts call this immediately before a widget's Render.
func SetWidgetItem ¶
SetWidgetItem records what the current page is about. Hosts call this before rendering widget regions on an item page.
func SiteWritable ¶
SiteWritable is the question almost every caller actually has: may I write?
func ViewSubject ¶
ViewSubject returns the user id whose profile is being rendered. Plugins call this inside a user.widget / user.tab Render.
func WidgetConfig ¶
WidgetConfig returns the setting an operator typed for THIS placement, or "" when there is none.
A widget must treat "" as "not configured" and render nothing, rather than falling back to a built-in default: an operator who cleared the field meant to clear it, and a widget that quietly reverts to sample text is one nobody can turn off.
Types ¶
type Access ¶
type Access struct {
Registration RegistrationMode
Viewing ViewingMode
// Indexable mirrors the host's SEO switch: when true the site emits
// sitemaps, Open Graph tags and a permissive robots.txt.
//
// It is NOT an access control and must never be used as one. robots.txt is
// a request, honoured by the search engines that choose to and ignored by
// everything else — and a page an anonymous visitor can fetch is public
// whatever this says. Viewing is the boundary; Indexable only decides
// whether we advertise.
Indexable bool
}
Access is the site's current access posture. Read it with Core.Access(), which is cheap enough to call per render.
func (Access) InvitesRequired ¶
InvitesRequired reports whether signing up needs an invite code.
func (Access) PublicBrowsing ¶
PublicBrowsing reports whether anonymous visitors may read public pages.
func (Access) SignupAllowed ¶
SignupAllowed reports whether a visitor can create an account at all, with or without a code. False means an admin has to do it.
func (Access) Validate ¶
Validate rejects a mode string that is not one of the known values.
Worth failing on rather than defaulting: a typo in a config file or a stray value in the settings table would otherwise fall through to the closed default and lock every visitor out of a site whose operator believes it is public — or, in the direction that actually matters, a future rename could silently reopen registration.
type AuthAdapter ¶
type AuthAdapter struct {
OptionalFn func() gin.HandlersChain
AuthenticateFn func() gin.HandlersChain
RequireUserFn func(minRole Role) gin.HandlersChain
RequireRoleFn func(role Role) gin.HandlersChain
CurrentUserFn func(c *gin.Context) (*User, bool)
}
AuthAdapter bundles the function references a host (cmd/main.go) supplies to construct the live AuthService.
type AuthService ¶
type AuthService interface {
// Optional loads the session user into the context when one
// exists but never blocks — anonymous requests proceed. For
// pages that are public even in closed mode (roadmap, status)
// yet render differently for logged-in viewers.
Optional() gin.HandlersChain
// Authenticate is the host's standard session policy chain:
// it loads the user into the request context and enforces the
// site's access mode (closed mode: anonymous requests redirect
// to login; public mode: anonymous browsing is allowed and
// write paths are gated). Use this for plugin pages that
// should match the site's default page policy; use RequireUser
// instead for pages that always need an authenticated user
// regardless of mode.
Authenticate() gin.HandlersChain
// RequireUser is a gin middleware chain that aborts the
// request when no user is in the session, OR when the user's
// role is below minRole. Use RequireUser(RoleUser) for "any
// authenticated user" — RoleUser is the default new-account
// level.
RequireUser(minRole Role) gin.HandlersChain
// RequireRole is a gin middleware chain that aborts when the
// user does not have EXACTLY the given role. Use sparingly —
// AtLeast semantics (RequireUser) is usually what you want.
RequireRole(role Role) gin.HandlersChain
// CurrentUser returns the user attached to the request, if
// any. The second return is false on anonymous requests. The
// returned *User is the plugin-facing trimmed type, not
// pkg/models.User.
CurrentUser(c *gin.Context) (*User, bool)
}
AuthService gives plugins reusable middleware + the current-user accessor. CurrentUser returns (nil, false) on anonymous requests so plugin handlers can short-circuit without a panic.
RequireUser / RequireRole return a middleware CHAIN, not a single handler, because the first element is the host's existing session-auth middleware, which calls c.Next() internally. Invoking it manually from inside a wrapper handler would run the rest of the chain (including the route handler) BEFORE the role gate — so the gate must be its own chain element. Apply with the spread form:
g.Use(c.Auth.RequireUser(core.RoleUser)...)
Note: this interface intentionally does NOT expose login, logout, password-change, or MFA flows. Those belong to the core auth handlers and are not pluggable.
func NewAuth ¶
func NewAuth(a AuthAdapter) AuthService
NewAuth constructs an AuthService from the supplied adapter. The identity-loading callbacks (Optional/Authenticate) fall back to a permissive empty chain when nil — pages render anonymous, degraded but harmless. The GATES (RequireUser/RequireRole) fail CLOSED: a nil callback yields a chain that aborts every request with 503, because "auth not configured" silently meaning "no gate" turns one misplaced Processes edit into anonymous admin routes with zero boot-time signal.
type CSRFTokenFunc ¶
CSRFTokenFunc mints the token for one request. Registered AS this type — a bare func never survives the registry's type assertion.
type ConfigService ¶
type ConfigService interface {
// Plugin returns the raw sub-map keyed by plugin name, or
// an empty (non-nil) map if no config section exists.
// Mutating the returned map is undefined — treat it as
// read-only.
Plugin(name string) map[string]any
// PluginInto unmarshals the named plugin's config section
// into dst. dst must be a pointer to a struct (or
// map[string]any). Missing section → no-op, no error;
// malformed section → error.
PluginInto(name string, dst any) error
}
ConfigService is the typed per-plugin config accessor.
Plugin config lives under the top-level "plugins" key in config.yml (matching PLUGIN-ARCHITECTURE.md § 10):
plugins:
wiki:
review_quorum: 1
points_per_field_approve: 5
forum:
new_thread_min_role: user
PluginInto is the canonical entry point — plugins declare a struct with mapstructure tags and unmarshal directly into it. Plugin() is the escape hatch for fully dynamic config (key/ value pairs whose names aren't known until the plugin runs).
func NewConfig ¶
func NewConfig(snapshot map[string]any) ConfigService
NewConfig constructs a ConfigService from a snapshot map. The snapshot is typically the contents of the top-level "plugins" key from viper at boot — see cmd/main.go for the canonical assembly path. A nil snapshot yields a service where every Plugin() returns an empty map and every PluginInto() is a no-op.
type Core ¶
type Core struct {
// Users is the read API every plugin uses to resolve users.
// Write operations are core-internal and NOT exposed.
Users UsersService
// Auth gives plugins reusable middleware + the current-user
// accessor. CurrentUser returns (nil, false) on anonymous
// requests.
Auth AuthService
// RBAC is the role-check facade. Plugins check role gates
// here rather than comparing role ints directly so the enum
// stays opaque.
RBAC RBACService
// Storage owns the shared DB pool + schema-scoped accessors.
// SchemaDB scopes search_path to the plugin schema so a
// plugin can write `SELECT * FROM threads` rather than
// `SELECT * FROM forum.threads`.
Storage StorageService
// Scheduler is the plugin-facing slice of GlobalJobRegistry.
// Plugins MUST register periodic work here — no bare
// goroutines.
Scheduler SchedulerService
// Router exposes pre-wired gin route groups. All three
// inherit the host middleware stack (CSRF, traffic,
// maintenance, IP-ban).
Router RouterService
// Logger is the root structured logger. Plugins receive a
// child tagged plugin=<name> via Core.LoggerFor(name).
Logger *slog.Logger
// Config is the typed per-plugin config accessor. PluginInto
// is the canonical entry point; Plugin() is the escape hatch
// for fully dynamic config.
Config ConfigService
// Notifications routes through the bell / email / Discord
// pipeline that core owns.
Notifications NotificationsService
// Points is the points-ledger facade (award / escrow /
// deduct).
Points PointsService
// Entitlements answers named per-user access questions
// (Has/Limit) and takes grants from source plugins
// (Grant/Revoke). The fine-grained layer between the Role
// ladder and per-feature rules — see entitlements.go.
Entitlements EntitlementsService
// HTTPClient is the SSRF-safe outbound HTTP factory. Raw
// &http.Client{} is forbidden in plugin code — every
// outbound fetch must come from here so the SSRF guard,
// timeout pool, and (optional) egress-proxy wiring stay
// applied.
HTTPClient HTTPClientService
// Errors routes errors into the error_logs table behind
// /admin/errors. Plugins call this instead of importing
// pkg/services.LogServiceError or web/handlers.JSONInternalError.
Errors ErrorReporter
// Redis is a shared Redis client for plugins that need one (e.g.
// the usenet redis staging backend). It is the ONE OPTIONAL
// subsystem: unlike every field above, a host may run without
// Redis, in which case this is nil. New does NOT require it.
// Plugins MUST nil-check — `if c.Redis == nil { ... }` — and
// degrade (the usenet plugin refuses `staging: redis` when it's
// absent rather than silently falling back). See redis.go.
Redis RedisService
// SiteState is what the site is currently willing to do — normal,
// read-only, or maintenance. See sitestate.go for why this is a core
// concern rather than each plugin's business.
//
// OPTIONAL, like Redis: a host that has not adopted site state leaves it
// nil. Do NOT nil-check it at every call site — use core.SiteWritable(ctx,
// c) or core.SiteStateOf(ctx, c), which report SiteNormal for a nil
// implementation. That default is deliberate: the contract is fail open, so
// an unknown mode must never turn a working request into an error.
SiteState SiteStateService
// Process identifies which process kind this Core was built
// for: "web", "worker", or "all" (single-process mode). Boot
// uses it to filter plugins (Metadata.Processes); dual-
// process plugins read it in Provision to decide which of
// their surfaces to wire.
Process string
// Flavours is which HALVES of a site this deployment runs:
// FlavourIndexer, FlavourTracker, or both. Boot uses it to
// filter plugins (Metadata.Flavours).
//
// EMPTY MEANS EVERY FLAVOUR, so a host that has never heard
// of flavours keeps every plugin it had. That default is
// load-bearing rather than polite: the alternative is a field
// arriving in a shared core and silently switching off half
// of somebody's site at the next build.
Flavours []string
// FeatureState answers whether a switchable capability is on
// (features.go). OPTIONAL: nil means the host has adopted no
// feature flags, and every feature reports its registered
// default — which is what a host that has never heard of them
// must keep getting.
FeatureState FeatureService
// contains filtered or unexported fields
}
Core is the mediator every plugin consumes. It is constructed exactly once in cmd/main.go (via New — see new.go) before any plugin's Provision runs, and is immutable thereafter — the fields point to live services, but Core itself never mutates. The one exception is the extension registry (extensions.go), which plugins append to during Provision.
Adding a new field is a one-way ratchet: once shipped, plugins may rely on it. Removing or changing a method signature is a coordinated refactor (acceptable — this is an internal interface, not a public API; see PLUGIN-ARCHITECTURE.md § 14).
Every field is an INTERFACE so that:
- The concrete impl is constructed in cmd/main.go from the existing services (composite.Storage, the gin router, the notification service, etc.) without pkg/core having to import any of those packages directly.
- Plugins can be tested against trivial stubs without booting the entire site (Tier 3 test pattern, design doc § 11).
func New ¶
New validates d and assembles the Core mediator. It fails loud: any nil field is an error, and the error names every missing field. cmd/main.go treats the error as fatal — a plugin that nil-panics at request time because Core.Users was never set is strictly worse than refusing to boot.
func (*Core) Access ¶
Access returns the site's current posture.
A host that has never called SetAccess gets the CLOSED answer for both: members-only viewing, no registration, not indexable. That is deliberate and it is the safe direction. A host that forgets to load its settings and therefore cannot register anybody has a loud, immediate, obvious bug; a host that forgets and silently serves the whole site to anonymous crawlers has a quiet one it may not notice for months.
func (*Core) AllViews ¶
AllViews returns every registered view for a slot INCLUDING the ones a switched-off feature hides.
For the admin surfaces that have to show what exists rather than what is currently on — a feature page listing what a toggle governs, a route mounter that must mount everything and refuse at request time. Everything else wants Views.
func (*Core) AllWidgets ¶
AllWidgets returns every registered widget INCLUDING the ones a switched-off feature hides.
The counterpart to AllViews, and for the one caller that needs it: an admin page naming what a feature toggle governs has to list a widget whether it is currently on or off, which is precisely what Widgets refuses to do.
func (*Core) DeclareEvent ¶
DeclareEvent announces that this plugin emits an event.
Declaring is not required to Emit — an undeclared event still delivers, because failing a member's action over a missing doc comment would be absurd. It is required to be DISCOVERED: an undeclared event does not appear in the directory, so the only way to learn it exists is to read the emitter's source, which is the problem this whole thing exists to remove.
func (*Core) Emit ¶
Emit delivers an event to every subscriber, in subscription order, synchronously.
Synchronous on purpose. The alternative is a queue, and a queue that loses its contents when the process restarts is worse than a handler you can see blocking — it turns "the achievement did not fire" into an unfalsifiable claim. A handler with real work to do spawns its own goroutine; that is the handler's decision to make, and it is visible in the handler.
A panicking subscriber is contained. The member's post has already happened, and one listener's bug must not unwind the action that announced it, nor stop the other listeners: a half-delivered event is the failure mode nobody would ever diagnose.
func (*Core) EventSubscribers ¶
EventSubscribers returns who listens to an event, by owner name, in subscription order.
func (*Core) ExtensionDefinition ¶
func (c *Core) ExtensionDefinition(name string) (ExtensionDef, bool)
ExtensionDefinition returns what an extension said about itself, if anything. The second return is false for one registered with the plain string form, which is most of them and not a problem — it means the directory shows a name and a type for it rather than a name, a type and a sentence.
func (*Core) ExtensionNames ¶
ExtensionNames returns a sorted snapshot of every registered extension name. Used by /admin/plugins to show what each plugin publishes.
func (*Core) FeatureByKey ¶
FeatureByKey resolves one declaration.
func (*Core) Features ¶
Features is the catalogue, ordered by key so an admin page does not reshuffle between loads.
func (*Core) LoggerFor ¶
LoggerFor returns a child logger tagged with plugin=<name>. Returns the root logger unchanged if Logger is nil (which only happens in early-boot tests). Cheap — slog handles do their own copy-on-write.
func (*Core) Lookup ¶
Lookup returns the service registered under name. The second return is false when nothing is registered — consumers that declared the provider in Metadata.Requires may treat false as a wiring bug and error out of Provision.
func (*Core) MissingExtensions ¶
MissingExtensions returns the capability names that were looked up and never found, sorted, with how many times each was asked for.
Read after boot to report what a host did not wire. A non-empty result is not necessarily a fault -- optional capabilities exist and a host may legitimately decline them -- but it should never be a SURPRISE, which is exactly what it has been every time so far.
func (*Core) On ¶
func (c *Core) On(name, owner string, fn EventHandler)
On subscribes to an event by name.
owner is the subscribing plugin, recorded so the directory can say who listens — the question the extension registry cannot answer about itself, and the first one asked when deciding whether a seam is safe to change.
Subscribing to an event nobody declares is allowed and silent: plugins provision in dependency order, but a host may simply not have the emitter installed, and a listener for an event that never fires is the correct behaviour rather than an error. The directory shows those as orphans.
func (*Core) Register ¶
Register publishes svc under name. Returns an error on an empty name, a nil service, or a duplicate registration — the caller (a plugin's Provision) should propagate it so Boot fails fast.
See RegisterDef to publish a description alongside it.
func (*Core) RegisterDef ¶
func (c *Core) RegisterDef(def ExtensionDef, svc any) error
RegisterDef publishes svc AND what it is for.
Same registry, same Lookup, same duplicate rule — the only difference is that the directory can describe this one. Prefer it for anything another repo consumes; a seam whose meaning lives only in the head of whoever wrote it is a seam that gets reimplemented next to itself.
func (*Core) RegisterFeature ¶
RegisterFeature declares a switchable capability.
Called from Provision, like RegisterView and RegisterWidget. A duplicate key is an error rather than a silent overwrite: two plugins claiming one switch means an operator toggling one of them and surprising the other.
func (*Core) RegisterView ¶
RegisterView publishes a view for the host to mount. Typically called from Provision in the web/all process. (Slot, Slug) must be unique; Render is required.
func (*Core) RegisterWidget ¶
RegisterWidget publishes a placeable widget. Typically called from Provision.
Slug must be unique across ALL widgets, not per-region — the whole point is that a widget is not bound to one place, so a per-region namespace would be meaningless and an operator's placement would become ambiguous.
func (*Core) SetAccess ¶
SetAccess publishes the posture. The host calls it once at boot from persisted settings, and again whenever an operator changes one — the change then applies to the very next request without a restart, which is how the site's own toggles already behave.
Core does not persist this. The settings table is the host's, and a framework that wrote to it would need to know its shape.
func (*Core) SubscribedEventNames ¶
SubscribedEventNames returns every name anyone subscribed to, declared or not. The directory uses it to surface ORPHANS: a subscription to an event nothing declares, which is either a typo or an emitter this host did not install, and is indistinguishable from working until someone asks why a listener is quiet.
func (*Core) Views ¶
Views returns the registered views for one slot, in registration order, omitting any whose Feature is switched off.
The filter lives HERE rather than at each consumer because this is the one choke point every one of them goes through — a nav builder, an admin index, a route mounter. A host filtering for itself would have to remember at each, and the one it forgot would be a link to a page that is no longer there.
func (*Core) WidgetBySlug ¶
WidgetBySlug finds one widget. Hosts resolve stored placements through this, so a placement naming a widget that is no longer registered — a plugin switched off since — reports missing rather than rendering something else.
type Deps ¶
type Deps struct {
// Process is the process kind this Core serves: "web",
// "worker", or "all". Drives Boot's plugin filter.
Process string
// Flavours is which HALVES this deployment runs — FlavourIndexer,
// FlavourTracker, or both. Drives Boot's other plugin filter
// (Metadata.Flavours). OPTIONAL: leave it nil and every plugin
// runs, which is what a host that has never heard of flavours
// gets and must keep getting.
Flavours []string
Users UsersService
Auth AuthService
RBAC RBACService
Storage StorageService
Scheduler SchedulerService
Router RouterService
Logger *slog.Logger
Config ConfigService
Notifications NotificationsService
Points PointsService
Entitlements EntitlementsService
HTTPClient HTTPClientService
Errors ErrorReporter
// FeatureState answers whether a switchable capability is on
// (features.go). OPTIONAL: nil means the host has adopted no
// feature flags and every feature reports the default its plugin
// declared — which is what a host that has never heard of them
// must keep getting.
FeatureState FeatureService
// Redis is the sole OPTIONAL dep: leave it nil on a host without
// Redis. New does not validate it (see Core.Redis). Set it via
// core.NewRedis(client) when the host runs Redis.
Redis RedisService
// SiteState is the second OPTIONAL dep: what the site is currently willing
// to do (normal / read-only / maintenance). Leave it nil on a host that has
// not adopted site state — core.SiteWritable and core.SiteStateOf report
// SiteNormal for a nil implementation, which is both true for that host and
// the fail-open answer. New does not validate it. See sitestate.go.
SiteState SiteStateService
}
Deps carries every service New requires to assemble a Core. The field set mirrors Core exactly — see the field docs there. Every field is REQUIRED: New reports all missing fields in one error so the composition root (cmd/main.go) fixes the wiring in one pass instead of playing whack-a-mole.
Tests that only exercise a slice of Core may construct a &Core{} literal directly; production code MUST come through New so a half-wired mediator can never reach a plugin's Provision.
type EntitlementGrant ¶
type EntitlementGrant struct {
// Key is the dotted entitlement name ("dm.initiate").
Key string
// Val is the grant's value: 1 for booleans, the limit for
// numeric keys. Never negative.
Val int
// Source labels who granted this ("role" is reserved for the
// baseline; sources use stable names like "group:legend",
// "reputation", "admin"). Part of the row identity so each
// source owns exactly its own grants.
Source string
// ExpiresAt is the optional expiry; nil = no expiry. Expired
// grants stop counting at resolution and stores must not
// return them.
ExpiresAt *time.Time
}
EntitlementGrant is one grant row as it crosses the store port, and doubles as the baseline-entry shape in EntitlementsConfig (where Source and ExpiresAt are ignored — the baseline is derived from the role at resolution time, never stored).
type EntitlementStore ¶
type EntitlementStore interface {
// GrantsFor returns every NON-EXPIRED grant for one user, all
// sources. Order is irrelevant — composition is commutative.
GrantsFor(ctx context.Context, userID int64) ([]EntitlementGrant, error)
// UpsertGrant inserts or replaces the (userID, g.Key, g.Source)
// row with g.Val / g.ExpiresAt.
UpsertGrant(ctx context.Context, userID int64, g EntitlementGrant) error
// DeleteGrant removes the (userID, key, source) row; absent
// rows are a no-op, not an error.
DeleteGrant(ctx context.Context, userID int64, key, source string) error
}
EntitlementStore is the narrow persistence port the host injects (its user_entitlements table, or NewMemEntitlementStore for tests and table-less hosts). Core owns resolution, composition, and caching; the store is dumb rows.
type EntitlementsConfig ¶
type EntitlementsConfig struct {
// Store is the persistence port. Required for a functioning
// service; nil yields the fail-closed/fail-loud behavior
// described on ErrEntitlementsNotWired.
Store EntitlementStore
// RoleOf resolves a user's Role so the baseline below applies.
// Return (role, true, nil) for a known user, (0, false, nil)
// for a user that does not exist (no baseline, result still
// cacheable), or an error for a transient failure (no baseline
// this call, result NOT cached). nil disables the baseline.
RoleOf func(ctx context.Context, userID int64) (Role, bool, error)
// Baseline maps a MINIMUM role to the grants every user at or
// above that role holds implicitly (RoleMod ⇒ moderation keys).
// Evaluated at resolution time from RoleOf — never written to
// the store, so a role change takes effect within one cache
// window with no backfill. Entry Source/ExpiresAt are ignored.
//
// A baseline entry IS a grant: for a numeric key, Limit()
// returns it instead of the caller's def, permanently
// shadowing any host-configurable default. Numeric defaults
// belong in Limit's def argument at the call site; keep the
// baseline to boolean abilities unless a number truly is
// role-derived.
Baseline map[Role][]EntitlementGrant
// ReportErr, when set, receives the errors behind degraded
// read resolutions (store or role-lookup failures) that
// Has/Limit cannot return by design — wire it to the host's
// error capture so fail-closed is not also fail-silent. op is
// a stable label ("entitlements/grants-for"); err is the
// underlying failure. nil = silent.
ReportErr func(ctx context.Context, op string, err error)
// TTL bounds how stale a cached resolution may go (grant
// expiry, cross-process writes). 0 means DefaultEntitlementsTTL.
TTL time.Duration
// contains filtered or unexported fields
}
EntitlementsConfig configures NewEntitlements.
type EntitlementsService ¶
type EntitlementsService interface {
// Has reports whether the user holds a boolean entitlement key
// (any non-expired grant of the key with a value > 0, from any
// source, including the role baseline).
Has(ctx context.Context, userID int64, key string) bool
// Limit returns the numeric entitlement for key — the MAX value
// across all sources granting it — or def when no source grants
// the key at all. A grant with value 0 counts as "granted at 0",
// not as absence.
Limit(ctx context.Context, userID int64, key string, def int) int
// Grant upserts one (user, key, source) grant. val must be >= 0
// (use 1 for booleans). expiresAt nil means the grant does not
// expire on its own; a non-nil expiry is enforced at resolution
// time (the grant simply stops counting). Granting the same
// (user, key, source) again replaces val and expiry — extending
// a subscription is a re-Grant, so callers stay idempotent.
Grant(ctx context.Context, userID int64, key string, val int, source string, expiresAt *time.Time) error
// Revoke deletes one (user, key, source) grant. Revoking a
// grant that does not exist is not an error — sources revoke on
// membership change without checking first.
Revoke(ctx context.Context, userID int64, key, source string) error
// Invalidate drops the user's cached resolution so the next
// Has/Limit re-reads the store. Grant and Revoke invalidate
// automatically; call this only after writing grant rows
// through some other path (a bulk backfill job).
Invalidate(userID int64)
}
EntitlementsService answers "what may this user do, and how much?" as named, per-user grants — the fine-grained layer between the coarse Role ladder (RBAC / Auth.RequireUser) and per-feature business rules. See ENTITLEMENTS.md in the host repo for the full model; the short version:
- Readers make access DECISIONS here — Has("dm.initiate"), Limit("download.daily") — and never read the granting source's data (a paid rank, a group, a reputation tier) directly. That split is what lets grant sources live in plugins without every reader coupling to them.
- Grant SOURCES (a groups plugin, a reputation job, an admin action) write through Grant/Revoke, tagged with a source label so each source manages only its own rows and two sources can grant the same key without clobbering each other.
Composition across sources is deliberately simple: booleans OR, numeric limits take the MAX — the most generous grant wins. There is no deny/negative grant; removing access is Revoke, not a counter-grant.
Keys are dotted, host-defined names ("dm.initiate", "download.daily"). Core stores and resolves them opaquely; the host keeps a typed catalog (mirroring its ledger-type discipline) so a typo can't silently mint a new key.
Reads fail CLOSED per source: a resolution failure (store error, role-lookup error) omits that source's grants for that call — failures can only withhold access, never add it — and a degraded resolution is never cached, so a transient DB blip can't pin an under-granted answer for a whole cache window. Has may still return true from the sources that did resolve.
func NewEntitlements ¶
func NewEntitlements(cfg EntitlementsConfig) EntitlementsService
NewEntitlements builds the core-owned entitlements resolver: per-user cached composition of the role baseline plus every stored grant. Unlike Points/Users this is not a host adapter — resolution semantics (OR/MAX, fail-closed, source identity) are framework behavior, so every loon host resolves identically and only the row storage varies.
type ErrorAdapter ¶
type ErrorAdapter struct {
ReportFn func(ctx context.Context, op string, err error)
HandlerErrorFn func(c *gin.Context, op string, err error)
}
ErrorAdapter bundles the function references the host hands to NewErrorReporter. ReportFn corresponds to services.LogServiceError (or compatible); HandlerErrorFn corresponds to handlers.JSONInternalError.
type ErrorReporter ¶
type ErrorReporter interface {
// Report logs err to stderr AND persists a row to
// error_logs. Safe to call with a nil err (no-op).
Report(ctx context.Context, op string, err error)
// HandlerError is the gin-aware variant. It calls Report
// for the persistence side AND writes the standard
// 500-internal-server-error envelope back to the client.
// Use from plugin handlers in place of
// handlers.JSONInternalError.
HandlerError(c *gin.Context, op string, err error)
}
ErrorReporter routes plugin errors to the persistent error log (the error_logs table behind /admin/errors). Plugins MUST call this instead of writing to stderr directly OR calling pkg/services.LogServiceError / web/handlers.JSONInternalError — those still work but require imports that pkg/core doesn't want plugins to take.
The "op" argument is a stable label (e.g. "wiki/index", "forum/post-create") used to group occurrences for the admin merge view. Don't include user input or row IDs in op — those defeat the dedup behaviour.
func NewErrorReporter ¶
func NewErrorReporter(a ErrorAdapter) ErrorReporter
NewErrorReporter constructs an ErrorReporter from the given adapter. Either callback may be nil; in that case the implementation logs to stderr only (and, for HandlerError, writes a plain 500 to the response).
type Event ¶
type Event struct {
// Name is the declared event name, "forum.post.created".
Name string
// UserID is who did it. Zero means the system did it, which
// a subscriber counting per-member things must skip rather
// than credit to user 0.
UserID int64
// Count is how many the event represents. Almost always 1.
// A bulk import emits ONE event with Count: 50 rather than
// fifty events, so a subscriber can do one write.
Count int64
// Subject is what was acted on, when there is one — a post
// id, an NZB id. Free text because the emitter's id type is
// the emitter's business.
Subject string
// At is when it happened. Set by Emit when zero.
At time.Time
// Data is anything beyond the common shape, for the
// subscribers that need it. Assert it to the type the
// emitter's EventDef.Payload names; nil for most events.
Data any
}
Event is one thing that happened.
The common fields are deliberately few. Nearly every event on a site of this kind is "member X did a countable thing, to this subject, once" — so that shape is first-class and only the unusual event reaches for Data. A rich per-event struct per emitter would mean every subscriber imports every emitter's package, which is exactly the coupling this avoids.
type EventDef ¶
type EventDef struct {
// Name is "<plugin>.<thing>.<verb>": forum.post.created,
// auth.login, usenet.release.uploaded.
Name string
// Summary is one line: what happened, from the member's
// point of view.
Summary string
// Emitter is the plugin that fires it.
Emitter string
// Kind says who acted: a member, or the system. Required — the default
// would have to be one of them, and either default is wrong half the time
// in a way nothing would report.
Kind EventKind
// Payload describes Data when the event carries any, naming
// the concrete type a subscriber should assert to. Empty
// means the common fields are all there is.
Payload string
// Countable says this event is worth totalling per member —
// posts, uploads, logins. An achievement can be scored on a
// countable event; "member deleted their account" is an
// event nobody should build a threshold on.
Countable bool
// Stable is false for an event still finding its shape.
Stable bool
}
EventDef is what an emitter says about an event it produces.
Declared once at Provision, so the directory can list what exists BEFORE anything fires — which is the whole difficulty with events: a registry of services can be read off the registry, but an undeclared event is invisible until the moment it happens, and a subscriber cannot discover what to subscribe to by waiting.
type EventHandler ¶
EventHandler receives one event. It must be QUICK: delivery is synchronous, so a slow handler slows the member action that emitted it. A handler with real work to do should hand off to its own goroutine and return.
It returns nothing on purpose. A subscriber cannot fail the thing that already happened — the post is posted — so there is no error worth propagating, and an emitter that could be failed by a listener would be a dependency again.
type EventKind ¶
type EventKind string
EventKind says WHO ACTED, which is the first thing a subscriber needs and the thing that was previously only inferrable from UserID being zero.
Inferring it was not merely inelegant. auth.failed_login_spike carries a username — the account being guessed at — who is a victim rather than an actor, and the only thing stopping a subscriber counting it against them was a comment asking the emitter to leave UserID at zero. A field says it instead, and validation can then refuse the combinations that make no sense.
const ( // EventMember — a MEMBER did this. UserID is the actor, and counting it // against them is meaningful. EventMember EventKind = "member" // EventSystem — the site did it, or it happened TO the site. A crawler // indexing a release, an address failing to log in repeatedly. UserID may // name somebody involved, but they did not act and nothing should be // credited or blamed on them for it. EventSystem EventKind = "system" )
type ExtKind ¶
type ExtKind string
ExtKind says how a consumer USES an extension, which is the first thing they need to know and the one thing a Go type cannot tell them: `func(context.Context, int64) error` is the same signature whether you call it or implement it.
const ( // ExtService — the registrant offers behaviour and peers call // it. The common case: wiki.render, rewards.admin. ExtService ExtKind = "service" // ExtCallback — the arrow points the other way. Somebody else // (usually the HOST) registers an implementation, and the // plugin that owns the name calls it. rewards.units.<slug> is // this: the host supplies the counter, the rewards engine // invokes it on a tick. ExtCallback ExtKind = "callback" // ExtData — a value rather than behaviour. A catalogue, a // config set. rewards.sources is this. ExtData ExtKind = "data" )
type ExtensionDef ¶
type ExtensionDef struct {
// Name is the registry key, same as Register's string.
Name string
// Summary is one line: what a consumer gets. Not a paragraph
// — this renders in a table cell, and a def nobody can skim
// is a def nobody reads.
Summary string
// Kind is direction: do I call this, or supply it?
Kind ExtKind
// Since is the version that introduced it, when the owner
// tracks versions. Free text; absent is fine.
Since string
// Stable is false for a seam still moving. A consumer can
// depend on an unstable one — this only says they should
// expect to be broken, which is kinder than finding out.
Stable bool
}
ExtensionDef is what an extension says about itself.
Register(name, svc) remains the whole API for anyone who does not care; this is for the ones worth explaining. The registry could always report a name and, by reflection, a Go type — which answers "what do I assert to" and not "what is this for, and am I meant to call it or implement it". Nobody could answer those without reading the provider's source.
func (ExtensionDef) Validate ¶
func (d ExtensionDef) Validate() error
Validate reports whether a def is worth having. A def with no summary is strictly worse than no def: it takes the space the answer would occupy and gives nothing back.
type Factory ¶
type Factory func() Plugin
Factory constructs a fresh Plugin instance. Caddy-style: registration captures a constructor, not the value itself, so the registry stays pure and instances are created during boot after config is loaded.
type Feature ¶
type Feature struct {
// Key is the stable id, namespaced by the plugin that owns it —
// "comments.thanks", "mediainfo.screenshots". Stored against the site, so
// renaming one resets whatever the operator decided.
Key string
// Title is what the admin page calls it.
Title string
// Description says what turning it OFF stops, in the operator's terms
// rather than the code's. This is the field that makes the page usable:
// somebody deciding whether to switch something off needs to know what
// breaks, and "thanks" does not tell them whether the points already
// awarded are clawed back.
Description string
// Default is whether the feature is on for a site that has never decided.
// Almost always true — a feature shipping off by default is one nobody
// discovers.
Default bool
}
Feature is one switchable capability.
func (Feature) Namespace ¶
Namespace is the part of the key before the first dot — the plugin that owns it, by the convention keys follow. Derived rather than stored, because core has no notion of which plugin is provisioning and inventing one to fill in a display field would be a lot of machinery for a grouping header.
type FeatureService ¶
FeatureService is how a HOST answers whether a feature is on.
Two returns rather than one, because "off" and "no opinion" are different answers and collapsing them loses the registered default: a host that has never been asked about a feature must fall back to what the plugin shipped, not to false.
Expected to be served from memory. This is called per request, sometimes several times per page, and a host that reaches a database for each one has built a slow way to render the same page.
type HTTPClientService ¶
type HTTPClientService interface {
API() *http.Client
Media() *http.Client
HeavyImport() *http.Client
WithTimeout(d time.Duration) *http.Client
SafeFetch(timeout time.Duration) *http.Client
Whitelisted(timeout time.Duration, hosts ...string) *http.Client
Proxied(timeout time.Duration, proxyURL string) (*http.Client, error)
}
HTTPClientService is the SSRF-safe outbound HTTP factory every plugin shares. Raw &http.Client{} is forbidden in plugin code — every outbound fetch must come through here so the SSRF guard, the timeout pool, and (optional) egress-proxy wiring stay consistently applied.
Four named factory methods cover the existing helper set in pkg/httpclient:
- API() → pooled client for known trusted JSON APIs.
- Media() → pooled client tuned for larger transfers (cover art, scrape pages).
- HeavyImport() → pooled client tuned for long bulk imports.
- WithTimeout() → ad-hoc client with a custom timeout (still SSRF-safe — see SafeFetch below).
SafeFetch is the user-input-URL variant: when a plugin receives a URL from any user (even an admin), it MUST use SafeFetch rather than API/Media/HeavyImport. The SSRF guard inside SafeFetch refuses to dial RFC1918, loopback, link-local, cloud metadata, CGNAT, and multicast addresses.
Whitelisted is the variant for fetches whose URL is mediated by an external API whose response is itself constrained to a known host allowlist (anime/manga CDNs, for example). Proxied is the egress-proxy variant this interface always anticipated: a client whose traffic exits through a configured HTTP proxy (the site's VPN egress container), for upstreams that IP-block the server. It carries NO SSRF dial guard — the proxy address is private by nature, and a guarded dialer would refuse it — so it is for trusted, operator-configured destinations only; pin hosts at the URL level in the caller.
func NewHTTPClient ¶
func NewHTTPClient() HTTPClientService
NewHTTPClient returns the default HTTPClientService implementation, which delegates straight to the existing pkg/httpclient package. Phase 0 wraps rather than refactors — there is one source of truth for SSRF defaults and that source is pkg/httpclient.
type Job ¶
type Job interface {
// SetRunning marks the job in-flight (drives the admin view
// and the shutdown drain wait).
SetRunning()
// SetIdle marks the run finished and records the next
// expected run time.
SetIdle(next time.Time)
// SetError records a failed run's message.
SetError(msg string)
// Log appends a line to the job's admin-visible log.
Log(format string, args ...any)
// MarkOffPeak flags the job to skip scheduled runs while
// site traffic is above the admin-configured threshold.
// Returns the same Job for chained configuration.
MarkOffPeak() Job
// MarkWrites declares that this job MUTATES persistent state,
// so scheduled runs are held back while the site is read-only
// (see schedule.WriteGate). Returns the same Job for chaining.
//
// Declare it generously. Flagging a read-only job by mistake
// costs a pause during a maintenance window; MISSING one costs
// data, because a write landing during a migration's copy is
// lost at cutover with nothing logged anywhere.
MarkWrites() Job
// SetTrigger installs the manual-run callback the admin
// /admin/jobs "run now" button fires. Manual triggers bypass
// the off-peak gate.
//
// The callback runs SYNCHRONOUSLY, on the goroutine that pressed
// the button. Use it only for work that enqueues and returns;
// anything that actually runs wants SetTriggerAsync below.
SetTrigger(fn func())
// SetTriggerAsync installs manual-run WORK, spawned and panic-
// protected by the scheduler instead of by the caller.
//
// It exists because every trigger in this ecosystem was written
// SetTrigger(func() { go run(ctx) }) — spawning by hand, because
// "run now" must not block the request — and that hand-rolled
// goroutine sits outside the loop's recover. An unrecovered panic
// in ANY goroutine ends the process, so one job had two run paths
// with two outcomes: recovered and recorded on its timer, fatal
// when an operator pressed the button. The fatal one is the path
// somebody takes deliberately, while watching.
//
// Pass the work itself, with no `go`. A failure then records on
// the job and reaches the host's panic sink exactly as a scheduled
// tick's would — nobody reading /admin/jobs should be able to tell
// which path a failure arrived by.
SetTriggerAsync(work func())
// IsPaused reports whether an admin has paused this job.
//
// RunLoop already skips a paused job, so a loop-driven tick does
// not need to ask. This is for the MANUAL trigger, which does not
// go through the loop: without it, "run now" on a paused job runs
// it, which is not what pausing means.
IsPaused() bool
}
Job is the plugin-facing handle on one registered job. Mirrors the host registry's lifecycle surface without exposing its concrete type.
type LedgerEntry ¶
type LedgerEntry struct {
// Amount is signed: positive credits, negative debits.
Amount int
// Balance is the running balance AFTER this entry, so a UI can show
// the ledger without re-deriving it.
Balance int
// Type is the ledger catalog value ("earn_upload", "spend_store_purchase").
// Carries the verb prefix described on PointsService.
Type string
// Description is the free-form label written at award/deduct time.
Description string
// ReferenceID links the row to a domain entity (request id, item id);
// nil when the entry has no referent.
ReferenceID *int64
CreatedAt time.Time
}
LedgerEntry is one points transaction, as plugins see it.
Deliberately a flat DTO rather than the host's row type: core cannot import a host's models package, and the shape a plugin needs to render a table is stable even when the host's storage is not.
type MemEntitlementStore ¶
type MemEntitlementStore struct {
// contains filtered or unexported fields
}
MemEntitlementStore is the in-memory EntitlementStore: the test double for plugins and services, and a real store for hosts that have no database-backed grants yet (the demo site) — grants simply don't survive a restart there.
func NewMemEntitlementStore ¶
func NewMemEntitlementStore() *MemEntitlementStore
func (*MemEntitlementStore) DeleteGrant ¶
func (*MemEntitlementStore) GrantsFor ¶
func (m *MemEntitlementStore) GrantsFor(_ context.Context, userID int64) ([]EntitlementGrant, error)
func (*MemEntitlementStore) SetClock ¶
func (m *MemEntitlementStore) SetClock(fn func() time.Time)
SetClock is the test-only knob for moving time past ExpiresAt without real sleeps.
func (*MemEntitlementStore) UpsertGrant ¶
func (m *MemEntitlementStore) UpsertGrant(_ context.Context, userID int64, g EntitlementGrant) error
type Metadata ¶
type Metadata struct {
// Name is the canonical short ID. Must be lowercase, [a-z0-9_],
// and unique across the binary. Used as the Postgres schema
// name and the config namespace.
Name string
// Version is informational only — there is no plugin-version
// skew at runtime. Used for /admin/plugins display + structured
// logs.
Version string
// Description shows up in /admin/plugins.
Description string
// Requires lists plugin names this plugin's Provision/Start
// depends on. Core services (Users, Auth, RBAC, Storage,
// Scheduler) are always available — only list peer plugins
// here. Topo-sorted at boot; cycles fail with log.Fatal.
Requires []string
// Migrations is the embed.FS containing this plugin's
// per-schema migrations (plugins/<name>/migrations/*.sql).
// May be empty.
Migrations embed.FS
// Processes lists which process kinds this plugin runs in:
// "web" (registers routes, serves requests) and/or "worker"
// (background jobs, bots). Empty means web-only — the safe
// default, since a worker process has no router and a
// route-registering plugin booted there would nil-panic.
// Boot skips plugins whose Processes don't include the
// booting Core's Process (an "all"-process Core runs
// everything).
Processes []string
// Flavours lists which HALVES of a site this plugin belongs
// to: FlavourIndexer, FlavourTracker, or FlavourAny for the
// majority that do not care — a forum, a shop, a points
// ledger.
//
// SAY IT even when the answer is "any". Empty runs everywhere
// too and always will, because a plugin compiled against an
// older core has no field to fill in — but that leaves
// absence meaning two things at once, "belongs to both" and
// "nobody has thought about it", and only one of them is a
// decision. loon-plugins CHECKLIST.md section 1 requires the
// field; scripts/audit_flavours.py finds the ones that
// skipped it.
//
// Boot skips plugins whose Flavours share nothing with the
// booting Core's Flavours, exactly as it does for Processes,
// and for the same reason: a plugin that has no business
// running here should not be provisioned, mount routes, or
// start jobs. A tracker's hit-and-run enforcement on a site
// with no torrents is not merely useless — it has an admin
// page, a nightly sweep and warnings to issue, all about a
// swarm that does not exist.
//
// Declared BY THE PLUGIN rather than listed by the host,
// which is the whole point. A host that keeps the list keeps
// it wrong: it has to know that hitrun, seedlock and perks
// are tracker plugins, and the day somebody writes a fourth
// the host does not know about it. A plugin already knows
// what it is.
Flavours []string
}
Metadata is the plugin's static identity, used for registration, dependency ordering, /admin/plugins listing, and the migration runner.
type NavHint ¶
type NavHint struct {
// but not listed (reachable by URL / in-page links only).
Menu string
Group string
Weight int
}
NavHint suggests nav placement for page-type views.
type Notification ¶
type Notification struct {
// Kind is the preferences + dedup key (e.g. "forum_quote",
// "wiki:edit_approved"). Recipients who disabled the kind's
// inbox channel are silently skipped. Required.
Kind string
// Title is the bell headline ("alice quoted you"). Required.
Title string
// Body is the secondary line (thread title, excerpt). May be
// empty.
Body string
// Link is the click-through target ("/community/forums/
// thread/7#post-42"). May be empty — the bell row is then
// non-navigable.
Link string
// ActorID / ActorName identify the user whose action caused
// the notification. ActorID 0 means "no actor" (system
// events). When ActorID equals the recipient, the host skips
// delivery — you don't get notified about your own actions.
ActorID int64
ActorName string
}
Notification is the plugin-facing envelope. It mirrors the host's bell-notification row shape closely enough that domain notifications (forum quotes, wiki approvals) render exactly as they did before their surfaces became plugins.
type NotificationsAdapter ¶
type NotificationsAdapter struct {
NotifyFn func(ctx context.Context, userID int64, n Notification) error
}
NotificationsAdapter is the function-bundle the host hands to NewNotifications. The single NotifyFn callback carries the full envelope; the host maps it onto its concrete notification row + preference gate.
type NotificationsService ¶
type NotificationsService interface {
// Notify enqueues a notification for delivery. See the
// Notification field docs for the envelope contract.
// Returns an error only on impossible-to-recover failures —
// transient channel issues are logged and swallowed so a
// flaky webhook doesn't break the user-facing flow.
Notify(ctx context.Context, userID int64, n Notification) error
}
NotificationsService is the bell / email / Discord pipeline every plugin shares. Specific notification kinds (offer claimed, forum quote, thanks given) are domain-specific and are routed by the Kind string — the recipient's notification-preferences row controls which channel(s) fire.
func NewNotifications ¶
func NewNotifications(a NotificationsAdapter) NotificationsService
NewNotifications constructs a NotificationsService from the given adapter. A nil callback yields a no-op service — useful for tests.
type Plugin ¶
type Plugin interface {
// Metadata is returned once at registration; must not depend on
// Provision having been called. Cheap and pure (called in init()).
Metadata() Metadata
// Provision wires the plugin into the host. The plugin captures
// whatever it needs from c (router groups, scheduler handle,
// logger) and stores it on its receiver for later use.
//
// MUST NOT: start goroutines, open external connections, run jobs.
// MAY: register routes, register jobs, validate plugin config,
// build internal services that depend on core services.
//
// Returning an error aborts boot — the binary fails fast rather
// than starting in a half-wired state.
Provision(c *Core) error
// Start kicks off any background work the plugin owns (job loops,
// cache refreshers, queue consumers). Called AFTER every plugin's
// Provision has succeeded, so a plugin may rely on peer plugins'
// services being wired (though not yet running).
Start(ctx context.Context) error
// Stop is called during graceful shutdown after the HTTP server
// has drained. The plugin must return when its background work
// has quiesced OR when ctx (a 15s shutdown budget) expires.
Stop(ctx context.Context) error
}
Plugin is the contract every internal module satisfies. Implementations register themselves at init() time via RegisterPlugin (see registry.go).
Lifecycle order at boot:
- init() RegisterPlugin called; metadata + factory captured.
- Provision() Core mediator handed in; plugin wires routes, services, jobs. May NOT do I/O or start goroutines.
- Start(ctx) Background work begins. ctx is the root context; when it cancels, all derived goroutines must exit.
- Stop(ctx) Drain goroutines. Bounded by ctx deadline (15s).
Migrations are not on this interface — they're declarative via Metadata.Migrations (an embed.FS) and applied by the migration runner in migrations.go, NOT by the plugin itself.
func LoadAll ¶
LoadAll instantiates every registered plugin and returns them topo-sorted by Metadata.Requires. Called once from cmd/main.go (and once again from RunMigrations, which independently needs the topo order).
LoadAll panics if a plugin's factory returns nil. It returns an error if any plugin's Metadata().Name disagrees with the name it was registered under (catches copy-paste bugs in plugin init()) — that situation could cause migrations to be applied to the wrong schema, so we fail loudly.
type PointsAdapter ¶
type PointsAdapter struct {
BalanceFn func(ctx context.Context, userID int64) (int, error)
AwardFn func(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
DeductFn func(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
RefundFn func(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
HistoryFn func(ctx context.Context, userID int64, limit, offset int) ([]LedgerEntry, int, error)
}
PointsAdapter is the function-bundle the host hands to NewPoints. All callbacks may be nil — in that case the service returns ErrPointsNotWired from every method so plugins that mistakenly rely on points before the real impl lands fail loudly rather than silently no-op.
type PointsService ¶
type PointsService interface {
// Balance returns the user's current points balance. 0 for
// unknown users.
Balance(ctx context.Context, userID int64) (int, error)
// Award credits N points. N must be > 0; otherwise the call
// is a no-op and the existing balance is returned.
Award(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
// Deduct debits N points. The impl refuses to take a user
// negative, returning ErrInsufficientPoints; plugins should
// treat that as a normal business-logic outcome rather than
// an internal error.
Deduct(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
// Refund credits back a previously-deducted amount (escrow
// returns, cancelled purchases). Ledger type carries the
// refund_ prefix so the credit never inflates the earned
// reputation tier.
Refund(ctx context.Context, userID int64, n int, reason, detail string, ref int64) (int, error)
// History returns one user's ledger entries newest-first, plus
// the total row count for paging (the total ignores limit/offset).
//
// The three mutators above let a plugin move points but never see
// them, so any plugin wanting to show a user their own transactions
// had to reach around this interface into host storage — which a
// plugin cannot do. That made "points" a facade you could write to
// and not read, and left the ledger UI stranded on the host.
//
// Read-only and self-scoped: it takes a single userID rather than a
// filter, so a plugin can render "your points history" but cannot
// mine the economy. Admin-wide views stay a host concern.
History(ctx context.Context, userID int64, limit, offset int) ([]LedgerEntry, int, error)
}
PointsService is the points-ledger facade. Plugins MUST go through this interface for any award / deduct / refund so that:
- The ledger row is written atomically with the user-balance update by the underlying impl.
- The reason lands in the typed points_ledger.type catalog (models.LedgerEntryType) so the admin Points Log and the reputation-tier queries see plugin activity like any other economy event.
reason is the ledger TYPE — a stable catalog value that MUST carry the verb's prefix: "earn_*" for Award, "spend_*" for Deduct, "refund_*" for Refund. (The earned reputation tier sums `type LIKE 'earn_%'`, and refunds deliberately do NOT count as income — see models.LedgerEntryType.) A mis-prefixed reason is not an error: the host maps it onto the generic earn_plugin / spend_plugin / refund_plugin types and moves the reason into the description, so the ledger stays consistent while the bug stays visible in the log.
detail is the free-form description column ("Applied to /c/ anime"). May be empty. ref is an optional points_ledger reference_id linking the row to a domain entity (request id, invoice id); pass 0 for none.
Mutating methods return the user's new balance to make "award N points and tell the user the running total" a one-call API.
func NewPoints ¶
func NewPoints(a PointsAdapter) PointsService
NewPoints constructs a PointsService from the given adapter.
type RBACService ¶
type RBACService interface {
// AtLeast returns true if u's role is >= minRole. Returns
// false for a nil user — anonymous requests never satisfy a
// role gate.
AtLeast(u *User, minRole Role) bool
// IsAdmin is a sugar form of AtLeast(u, RoleAdmin).
IsAdmin(u *User) bool
// IsMod is a sugar form of AtLeast(u, RoleMod). Note:
// admins ARE mods under this check (RoleAdmin > RoleMod) —
// use AtLeast(u, RoleMod) && !IsAdmin(u) for "strictly a
// mod, not an admin".
IsMod(u *User) bool
}
RBACService is the role-check facade. Plugins call methods here rather than comparing Role ints directly so the enum stays opaque — if we ever add a new role tier between Mod and Admin, every plugin's "is the actor at least a moderator" check still gives the right answer.
Most checks (`u.AtLeast(RoleMod)`) can also be done on the User type directly; this interface is here for symmetry with the design doc and for the rare cases where a check happens in middleware that doesn't have a *User in hand.
func NewRBAC ¶
func NewRBAC() RBACService
NewRBAC returns the default RBACService implementation. The rules live in this package (rather than in an adapter) because they are pure comparisons against the Role enum which itself is owned by this package — there is no external state to wire in.
type RedisService ¶
type RedisService interface {
Client() redis.UniversalClient
}
RedisService hands plugins a shared Redis client. It exists for plugins whose backend genuinely needs Redis semantics a key/value cache can't express — pipelines, lists (BLPOP), hashes, TTLs — the first being the usenet plugin's `staging: redis` assembly buffer (a verbatim lift of the production pipeline).
Redis is OPTIONAL infrastructure. Unlike Storage/Auth/etc., a host may run with no Redis at all (the base site defaults every Redis-capable plugin to its durable Postgres mode), so `Core.Redis` is nil in that case and `New` does not require it. Consumers MUST nil-check `c.Redis` and degrade — never assume it.
The client type is go-redis' UniversalClient (single/sentinel/cluster) so the seam doesn't pin a topology, and the raw client is exposed (not a narrowed interface) precisely so a plugin can lift battle-tested Redis code unchanged instead of routing every command through a lowest-common-denominator wrapper. The HOST owns the client's lifecycle: it builds the client from its own config and Close()s it on shutdown (same ownership model as the shared *sqlx.DB pool — Core exposes, the host owns).
func NewRedis ¶
func NewRedis(client redis.UniversalClient) RedisService
NewRedis wraps a host-built client as a RedisService. Pass the result via Deps.Redis. A host without Redis simply omits Deps.Redis (leaving Core.Redis nil) rather than passing NewRedis(nil).
type RegistrationMode ¶
type RegistrationMode string
RegistrationMode says who may create an account.
const ( // RegistrationOpen — anyone may sign up. RegistrationOpen RegistrationMode = "open" // RegistrationInvite — an invite code is required. RegistrationInvite RegistrationMode = "invite_only" // RegistrationClosed — nobody may sign up; an admin creates accounts. RegistrationClosed RegistrationMode = "closed" )
type Role ¶
type Role int
Role is the plugin-facing role enum. The integer values mirror pkg/models.RoleLevel verbatim so the adapter is a trivial numeric cast, but plugins MUST use the named constants below rather than the raw integer — the enum is otherwise opaque.
type RouterAdapter ¶
type RouterAdapter struct {
Engine *gin.Engine
AdminMiddleware []gin.HandlerFunc
APIMiddleware []gin.HandlerFunc
}
RouterAdapter bundles the references the host hands to NewRouter. AdminMiddleware/APIMiddleware are stacks the host pre-builds (session auth + role check / API-key check); the constructor applies them to the admin/API groups so plugin authors don't have to remember.
Leaving either stack empty does NOT yield an open group — see unwiredStack. A process that cannot authenticate (an api-only process with no session middleware, say) should leave the admin stack empty deliberately and let the group refuse, rather than hand plugins an unguarded /admin tree.
type RouterService ¶
type RouterService interface {
// Mount returns the public route group rooted at
// /plugin/<name>/.
Mount(pluginName string) *gin.RouterGroup
// Admin returns the admin route group rooted at
// /admin/plugin/<name>/ with RequireRole(Admin) and the
// session-auth middleware already applied.
Admin(pluginName string) *gin.RouterGroup
// API returns the API route group rooted at
// /api/plugin/<name>/ with API-key authentication already
// applied.
API(pluginName string) *gin.RouterGroup
// Engine returns the underlying *gin.Engine for the rare
// case a plugin needs to register a route OUTSIDE the
// /plugin/<name>/ tree (e.g. domain-specific paths like
// /wiki/). Most plugins should NOT need this.
Engine() *gin.Engine
}
RouterService exposes pre-wired gin route groups for plugins. All three groups inherit the host middleware stack (CSRF, traffic, maintenance, IP-ban) because they are derived from the same *gin.Engine that the site is already serving with; the plugin only adds plugin-specific gates on top.
Path conventions (see PLUGIN-ARCHITECTURE.md Appendix A):
- Mount("foo") → /plugin/foo/* (public + authed pages)
- Admin("foo") → /admin/plugin/foo/* (RequireRole(Admin) pre-wired)
- API("foo") → /api/plugin/foo/* (API-key auth pre-wired)
Domain-specific top-level paths (e.g. /wiki/, /forum/) are also acceptable; plugins that prefer those simply mount on the root engine directly via Engine(). The /plugin/<name>/ scheme is the default so a new plugin can ship without arguing over root-namespace conflicts.
func NewRouter ¶
func NewRouter(a RouterAdapter) RouterService
NewRouter constructs a RouterService over the given engine. Passing a nil engine yields a router whose methods return nil — useful for tests that exercise non-HTTP code paths.
type Runtime ¶
type Runtime struct {
// contains filtered or unexported fields
}
Runtime is the handle Boot returns to the host. It captures the live plugin slice so cmd/main.go can drive a graceful Stop without re-running the topo sort.
func Boot ¶
Boot is the single entry point cmd/main.go calls after the legacy wiring has finished and before HTTP serving begins.
It performs five things in order:
- Apply any pending plugin migrations (via RunPluginMigrations).
- Topo-sort registered plugins.
- Call Provision on every plugin in order (route + service wiring).
- Call Start on every plugin in order (background work begins).
- Return a Runtime handle the host uses to drive Stop on SIGTERM.
In Phase 0 no plugins are registered, so Boot reduces to step 1 (the migration runner confirms core.plugin_migrations exists, then returns immediately because no plugin contributes any FS entries) and step 5 (returns a Runtime with an empty plugin slice). Existing site behaviour is identical.
Returning an error from Boot means "do not start the HTTP server" — the half-wired state would do more harm than dying. cmd/main.go propagates this to log.Fatal.
func (*Runtime) Core ¶
Core returns the kernel the runtime booted, so a host can reach the registries that outlive boot — views, widgets, extensions — from the one value it already keeps.
Read-only in spirit: this is for asking what plugins published, not for registering more after boot. nil-safe like Plugins, because a host that failed to boot still renders an error page.
func (*Runtime) Plugins ¶
Plugins returns the live, topo-ordered plugin slice. Read- only — modifying the slice has no effect on the runtime.
func (*Runtime) Stop ¶
Stop signals every registered plugin to drain its background work. Plugins are stopped in REVERSE topo order so a plugin can rely on its dependencies still being alive while it quiesces. Errors are logged but do not abort the loop — at shutdown we drain as much as we can in the budget.
ctx is the shutdown deadline (the host wires this to a 15s budget per the design doc); plugins MUST return when ctx expires even if their background work is mid-flight.
type SchedulerAdapter ¶
type SchedulerAdapter struct {
RegisterJobFn func(name, desc string) Job
RunLoopFn func(ctx context.Context, job Job, bootDelay, defaultInterval time.Duration, runFn func(context.Context))
}
SchedulerAdapter bundles the callbacks the host supplies to NewScheduler. RegisterJobFn wraps the host job registry; RunLoopFn wraps its service loop.
type SchedulerService ¶
type SchedulerService interface {
// RegisterJob captures a new periodic job. Call during
// Provision — registering at Start time races the admin
// view's registry snapshot. name should be namespaced by the
// plugin ("offers: Sweeper") since the registry is global.
RegisterJob(name, desc string) Job
// RunLoop runs runFn on the configured cadence, honouring
// the host's off-peak gate, admin interval overrides, and
// root-context cancellation. Returns immediately; the loop
// runs on its own goroutine and exits when ctx cancels.
RunLoop(ctx context.Context, job Job, bootDelay, defaultInterval time.Duration, runFn func(context.Context))
}
SchedulerService is the plugin-facing job scheduler. Plugins MUST register periodic work here rather than spawning bare goroutines — the host implementation wires jobs into its registry (admin visibility, manual triggers, off-peak gating, shutdown draining), and a bare goroutine bypasses all of it.
This interface is self-contained by design: the previous version leaned on the host's concrete job machinery (pkg/services.JobInfo / ServiceLoop), which was the last app-package dependency inside the kernel — a blocker for extracting core into the standalone framework. The host now adapts its machinery to these interfaces instead.
func NewScheduler ¶
func NewScheduler(a SchedulerAdapter) SchedulerService
NewScheduler constructs a SchedulerService from the adapter. Nil callbacks degrade to inert no-ops (jobs register as stubs and loops never run) — useful in tests; production callers supply both.
type SchemaDB ¶
type SchemaDB struct {
// contains filtered or unexported fields
}
SchemaDB is a thin wrapper around *sqlx.DB that arranges for search_path to be scoped to the plugin schema for every connection-bound operation.
The simplest correct way to do that in Postgres is to run `SET LOCAL search_path = <schema>, public` inside a transaction (the `LOCAL` is the important bit — it confines the change to that one transaction so we never pollute the pool). All write paths therefore go through Tx() / WithTx(); quick read-only queries use ExecContext / QueryContext which run a one-shot `SET search_path` per call.
In Phase 0 no plugin actually uses this wrapper (no plugins are registered) — the surface is here so that when the wiki plugin lands in Phase 1 it can be migrated with no further scaffolding work.
func (*SchemaDB) DB ¶
DB returns the underlying *sqlx.DB. Useful for tests that want to assert against the same pool, or for plugin code that needs a feature the wrapper does not yet expose (LISTEN/NOTIFY, COPY, etc.). When you use this directly, you are responsible for setting search_path yourself if you want unqualified table names to resolve inside the plugin schema.
type SiteMode ¶
type SiteMode string
SiteMode is what the site is currently willing to do. Closed set, compared against constants and never against string literals: a mistyped literal in a comparison silently takes the wrong branch, and the wrong branch here means accepting writes during a migration.
const ( // SiteNormal is everything working. The default, and what an absent // SiteState implementation must report. SiteNormal SiteMode = "normal" // SiteReadOnly serves reads and refuses writes. The site is UP: members // browse, search and download normally, and the pages that would change // something say why they cannot. SiteReadOnly SiteMode = "read-only" // SiteMaintenance is the existing all-stop, where even reads are refused. // Distinct from read-only on purpose: conflating them is how "we are doing // maintenance" came to mean "the site is gone", which is exactly the habit // read-only exists to break. SiteMaintenance SiteMode = "maintenance" )
func SiteStateOf ¶
SiteStateOf reads the mode from a Core, tolerating a Core that has no SiteState wired.
Every plugin would otherwise write the same nil-check, and the ones that forgot would panic in the mode that exists to avoid an outage. A host that has not adopted site state reports SiteNormal, which is both the truth for that host and the fail-open answer.
type SiteStateService ¶
type SiteStateService interface {
// Mode is the current site mode. Cheap enough to call per request: hosts
// are expected to serve it from memory and refresh in the background,
// exactly as the existing maintenance flag does.
Mode(ctx context.Context) SiteMode
// Reason is operator-supplied text for why, shown to members in the banner
// ("upgrading the database, back shortly"). Empty is fine and the UI must
// cope: a mode with no explanation is still a mode.
Reason(ctx context.Context) string
}
SiteStateService is how a plugin asks what the site is currently willing to do.
FAIL OPEN is the contract, and it is not a suggestion. Callers use this to decide whether to attempt a write, and a caller that cannot reach the answer must behave as though writes are allowed rather than break the request: several read paths write as a side effect (a download records a grab, an API call records a request, an agent poll stamps last_seen), so a service that turned an unavailable mode into an error would take the site down in the mode meant to keep it up. Hence Mode has no error return — an implementation that cannot determine the mode reports SiteNormal.
type StorageService ¶
type StorageService interface {
// DB returns the raw shared connection pool. Use this for
// cross-schema reads (joining a plugin schema against
// public.users, for example).
DB() *sqlx.DB
// SchemaDB returns a *SchemaDB scoped to the named plugin
// schema. Every query through that wrapper runs with
// `SET LOCAL search_path = <schema>, public` so unqualified
// table references resolve inside the plugin's schema first.
SchemaDB(plugin string) *SchemaDB
// NoRowsAsNil collapses sql.ErrNoRows to a nil error so the
// caller's contract becomes "return zero value if nothing
// matches" rather than "return a typed not-found error".
// Re-exported so plugins don't have to import pkg/storage.
NoRowsAsNil(err error) error
}
StorageService owns the shared DB pool + schema-scoped accessors. The pool itself is intentionally shared across plugins (one Postgres database, many schemas — see PLUGIN-ARCHITECTURE.md § 6) so connection budgeting stays a single tuning knob.
SchemaDB scopes search_path to the plugin schema so a plugin can write `SELECT * FROM threads` rather than `SELECT * FROM forum.threads`. The fully-qualified form is still allowed for cross-schema reads (e.g. JOIN to core.users).
func NewStorage ¶
func NewStorage(db *sqlx.DB) StorageService
NewStorage constructs a StorageService over the given pool. Passing nil is allowed for tests; methods that would otherwise hit the pool return zero values without panicking.
type User ¶
User is the read-only projection of a user that plugins see. The fields are intentionally minimal: ID for FKs, Username for display, Email for transactional mail (decrypted), Role for permission checks, CreatedAt for "joined N days ago" UI.
Anything plugin-specific (Points, TOTP, password hashes, invite counts, etc.) is INTENTIONALLY excluded — those fields either belong to a different service (PointsService) or are core-internal (auth surface only).
func (*User) AtLeast ¶
AtLeast returns true if the user's role meets or exceeds the given level. Provided as a method so plugins can write `u.AtLeast(core.RoleMod)` without dereferencing a separate RBAC service for every check. The RBACService facade still exists for the "no User in hand" case (e.g. inside middleware) and for consistency with the design document.
type UsersAdapter ¶
type UsersAdapter struct {
GetByIDFn func(ctx context.Context, id int64) (*User, error)
GetByUsernameFn func(ctx context.Context, name string) (*User, error)
DisplayNameFn func(ctx context.Context, id int64) (string, error)
BulkDisplayNamesFn func(ctx context.Context, ids []int64) (map[int64]string, error)
}
UsersAdapter is the function-bundle a host (cmd/main.go) passes into NewUsers to construct the live UsersService. We take callbacks rather than a *composite.Storage so this package keeps zero dependency on pkg/storage — the same pattern is used for every Core sub-service.
type UsersService ¶
type UsersService interface {
// GetByID returns the user with the given primary-key ID.
// Returns (nil, nil) when no row matches — plugins should
// nil-check the return value rather than relying on a typed
// "not found" error.
GetByID(ctx context.Context, id int64) (*User, error)
// GetByUsername is case-insensitive (matches the existing
// CITEXT semantics on users.username). Same (nil, nil)
// convention as GetByID for missing rows.
GetByUsername(ctx context.Context, name string) (*User, error)
// DisplayName returns the rendered username for the given
// ID. Equivalent to `(GetByID().Username)` but cheaper since
// the impl can hit a per-process cache. Returns "" when no
// row matches — never errors on absence, only on DB faults.
DisplayName(ctx context.Context, id int64) (string, error)
// BulkDisplayNames resolves many IDs in one query. Used by
// list-views that need to render alongside a join (forum
// thread lists, wiki edit queues). The returned map omits
// IDs that didn't match — the caller's "unknown user"
// placeholder fills the gap.
BulkDisplayNames(ctx context.Context, ids []int64) (map[int64]string, error)
}
UsersService is the read API every plugin uses to resolve users. The WRITE surface (Create, UpdateEmail, ChangePassword, password rotation, MFA enrolment, …) is core-internal and is intentionally NOT exposed here — those flows belong to the auth/account handlers that core owns.
All methods are context-aware so plugins can propagate request deadlines and tracing handles down through the DB call.
The concrete adapter lives in cmd/main.go and wraps the existing composite.Storage / models.User pair into core.User instances. See the doc comment on usersAdapter below.
func NewUsers ¶
func NewUsers(a UsersAdapter) UsersService
NewUsers constructs a UsersService from a UsersAdapter. Each nil callback degrades to a sensible default (returns nil/empty, never errors) so a partial wiring during incremental adoption doesn't crash plugin code that holds a Core reference.
type View ¶
type View struct {
Slug string // URL segment / stable id, unique per slot
Title string // nav label + page/card/tab title
Slot ViewSlot
Anchor string // slot-specific attachment point (SlotJobsWidget: job-group name)
// Description is an optional one-liner for host navigation surfaces — an
// admin-hub card subtitle, a nav tooltip. Hosts that render plugin pages
// dynamically (a card per registered view) read it so a plugin never needs
// a hand-edited entry in a host template. Empty is fine: hosts render the
// title alone.
Description string
// Visibility for site.* and user.* slots (admin.* slots additionally sit
// behind the host's admin gate regardless of these):
// Public true → anonymous viewers allowed
// Public false → viewer must be logged in with Role >= MinRole;
// the zero MinRole (RoleUser) means any account
Public bool
MinRole Role
// WordPress/Drupal pattern: code declares defaults; a host — or a future
// admin nav editor — may override). Hosts build their nav structure once
// at boot and only role-filter per request, so this costs nothing hot.
Nav NavHint
// Feature, when set, is the key of a core.Feature this view belongs to
// (features.go). With that feature switched off the view is not listed by
// Views, so it vanishes from every nav and index a host builds from it —
// and the host is expected to refuse its routes too, since a route mounted
// at boot stays mounted.
//
// Empty is the common case: a view not named by a feature is always on.
Feature string
Render func(c *gin.Context) (template.HTML, error)
Actions map[string]func(c *gin.Context) (template.HTML, error)
}
View is one plugin-rendered unit. Render returns an HTML FRAGMENT (no layout); the host wraps it. An action either writes its own response (redirect) and returns ("", nil), or returns a fragment the host re-renders in place — the form-preserving contract (e.g. test-connection keeping the submitted values).
func (View) AllowsAnon ¶
AllowsAnon reports whether anonymous viewers may see the view.
func (View) AllowsUser ¶
AllowsUser reports whether u (nil = anonymous) may see the view.
type ViewSlot ¶
type ViewSlot string
The view system: plugins register renderable UNITS — a full page, a tab, or a small widget ("blob") — and the host decides where each slot's units appear, wrapping every fragment in its own layout/nav/theme. The plugin owns the content; the host owns the chrome. This is how a plugin ships UI (settings, status pages, profile tabs, dashboard cards) without the host writing plugin-specific handlers.
A ViewSlot names the host surface a view attaches to. Hosts mount each slot by convention (fragments hardcode these URLs):
SlotAdminSettings section on the aggregated /admin/settings page;
actions POST /admin/settings/<slug>/<action>
SlotAdminPage standalone admin page GET /admin/p/<slug>;
actions POST /admin/p/<slug>/<action>
SlotJobsWidget replaces the default job table inside the host jobs
page's group card whose group name == Anchor (the
"list the basics, allow a custom override" contract);
no own URL, Actions ignored — buttons post to the
plugin's page/settings actions
SlotSitePage public-facing page GET /p/<slug> (+ actions
POST /p/<slug>/<action>), gated by Public/MinRole;
hosts list it in the site nav for allowed viewers
SlotSiteWidget card on the host's home/dashboard, gated by
Public/MinRole; Actions ignored
SlotUserWidget card in a user-profile summary; the SUBJECT (whose
profile) arrives via ViewSubject(c)
SlotUserTab tab on the user-profile page; subject via ViewSubject
type ViewingMode ¶
type ViewingMode string
ViewingMode says who may read the site's pages.
const ( // ViewingMembers — every page requires a login ("closed" on the wire). ViewingMembers ViewingMode = "closed" // ViewingPublic — anonymous visitors may browse public pages; writes and // member-scoped pages still require a login, and search engines can reach // whatever is left open. ViewingPublic ViewingMode = "public" )
type Widget ¶
type Widget struct {
// Slug is the stable id an operator's placement refers to. It outlives
// titles and translations, so renaming a widget must not move it.
Slug string
// Title labels the widget in the placement editor, and is the default
// heading a host may draw around the fragment.
Title string
// Description is one line for the editor's dropdown, so an operator
// choosing between widgets is not guessing from the slug.
Description string
// Visibility, matching View's rules so a plugin author learns one model:
// Public true → anonymous viewers allowed
// Public false → viewer must be signed in with Role >= MinRole
// A host MUST apply this per viewer; a placement says where a widget goes,
// never who may see it.
Public bool
MinRole Role
// Regions, when non-empty, restricts where this widget makes sense — a
// hint the editor uses to avoid offering a wide table for a narrow
// sidebar. Empty means "anywhere", which is the common case and the
// default a plugin should prefer: guessing a host's layout is exactly the
// coupling this package exists to remove.
Regions []string
// Weight orders widgets a host renders without an explicit placement
// (lower first; ties keep registration order). An operator's arrangement
// always wins over it.
Weight int
// ConfigLabel, when non-empty, declares that this widget takes a per-
// PLACEMENT setting and labels the field an operator types it into.
//
// Per placement, not per widget, is the whole point: a text widget in the
// footer and the same text widget in a sidebar are two different notices,
// and one shared value would make the second placement useless. The host
// stores the string against (region, slug) and hands it back through
// WidgetConfig at render.
//
// A plain string rather than a schema. Anything richer means the editor
// growing a form builder, and the widgets that actually want configuring —
// a notice, a heading, a feed url — want one value.
ConfigLabel string
// ConfigHint is placeholder text under the field: what to type, and what
// happens if it is left blank.
ConfigHint string
// Feature, when set, is the key of a core.Feature this widget belongs to
// (features.go). With that feature switched off the widget is not listed
// by Widgets and not resolvable by WidgetBySlug, so an existing PLACEMENT
// of it renders nothing — which is the behaviour that matters: an operator
// switching a feature off should not also have to go and un-place it, and
// should find it still placed when they switch it back on.
//
// Empty is the common case: a widget not named by a feature is always on.
Feature string
// Render returns an HTML fragment. Returning ("", nil) is the correct way
// to say "nothing to show here" — a host drops the widget entirely rather
// than drawing an empty box around it.
Render func(c *gin.Context) (template.HTML, error)
}
Widget is one placeable unit.
func (Widget) AllowsUser ¶
AllowsUser reports whether u (nil = anonymous) may see the widget.
func (Widget) FitsRegion ¶
FitsRegion reports whether the widget is willing to render in a region. A widget with no stated Regions fits everywhere.
func (Widget) TakesConfig ¶
TakesConfig reports whether an operator should be offered a setting field for this widget.
type WidgetItemRef ¶
WidgetItemRef identifies what a page is about, for widgets rendered on it.
Kind is the host's word for the thing ("release", "thread"), so a widget can refuse a page it has no answer for instead of assuming every id is the kind it wanted — an id alone is exactly how a release widget ends up rendering against a thread id and quietly showing the wrong row.
func WidgetItem ¶
func WidgetItem(c *gin.Context) (WidgetItemRef, bool)
WidgetItem returns what the page is about, if the host said.
Source Files
¶
- access.go
- admin.go
- auth.go
- boot.go
- config.go
- core.go
- csrf.go
- doc.go
- entitlements.go
- entitlements_mem.go
- errors.go
- events.go
- extensions.go
- features.go
- httpclient.go
- logger.go
- migrations.go
- models.go
- new.go
- notifications.go
- plugin.go
- points.go
- rbac.go
- redis.go
- registry.go
- router.go
- scheduler.go
- sitestate.go
- storage.go
- users.go
- views.go
- widgets.go