content

package
v0.83.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 37 Imported by: 1

Documentation

Overview

Package content is ContentKit's interaction module: comments, reactions, favorites and polls over tenant-scoped content references, stored in the host schema's content_* interaction tables. Everything host-specific lives behind the ports in this file; the package imports no sibling kit and bakes in no host assumption. Content kinds are host-registered, access is an opaque host verdict, ids are opaque text, and every key, index and cursor carries the tenant pinned at construction.

Index

Constants

View Source
const (
	CodeInvalidRequest     = "invalid_request"
	CodeUnauthorized       = "unauthorized"
	CodeForbidden          = "forbidden"
	CodeNotFound           = "not_found"
	CodeConflict           = "conflict"
	CodeModerationRejected = "moderation_rejected"
	// CodeRateLimited: too many of one interaction; the body carries action
	// and retry_after (seconds, as the Retry-After header). -> 429
	CodeRateLimited = "rate_limited"
	// CodeCommentBanned: the caller is banned from commenting on this target;
	// the body's ban says the scope, reason and until. -> 403
	CodeCommentBanned = "comment_banned"
	// CodeCommentsClosed: the target's comments are closed or hidden. -> 403
	CodeCommentsClosed = "comments_closed"
	// CodeCommentTooLong: the comment is longer than Options.CommentMaxLength;
	// details.max is the limit in characters. -> 422
	CodeCommentTooLong = "comment_too_long"
	// CodeNotConfigured: the capability exists but the host never wired its
	// port (Media, AnswerClassifier). Retrying does not help. -> 501
	CodeNotConfigured = "not_configured"
	// CodeTenantMismatch: a host port answered with another tenant's data.
	// A configuration fault, not a client fault; the cause stays in the log.
	CodeTenantMismatch = "tenant_mismatch"
	// CodeUnavailable: the host's auth could not answer a permission check;
	// retry. -> 503
	CodeUnavailable = "unavailable"
	CodeInternal    = "internal_error"
)

Stable public error codes: the machine-readable half of ContentKit's error body. Clients branch on Code; Error is a human message and may change.

View Source
const (
	ModerationApproved = "approved"
	ModerationHeld     = "held"
	ModerationRejected = "rejected"
)

Moderation states stored in content_comments.moderation and content_items.moderation.

View Source
const (
	PollMultipleChoice = "multiple_choice"
	PollFreeText       = "free_text"
)

Poll kinds.

View Source
const (
	PreferenceAxisReaction = "reaction" // value -1 dislike / 0 neutral / 1 like
	PreferenceAxisFavorite = "favorite" // value 1 favorited / 0 not
)

Preference axes: the signal Type each table exports under.

View Source
const (
	CommentsOpen   = "open"
	CommentsClosed = "closed" // existing comments show; new ones and edits are refused
	CommentsHidden = "hidden" // comments neither show nor take writes
)

A content item's comments setting (resolve.Resolution.Comments); groups' comments are always open.

View Source
const DefaultCommentMaxLength = 2200

DefaultCommentMaxLength is Options.CommentMaxLength unset.

View Source
const DefaultPreferenceSyncOverlap = 5 * time.Minute

DefaultPreferenceSyncOverlap is the default Options.PreferenceSyncOverlap.

View Source
const KindComment = "comment"

KindComment is the reserved content kind of comments: reactions on comments are keyed by it and never pass through the resolve.Resolver.

View Source
const (
	// ScopeGlobal is the whole tenant, managed by holders of root:comments:ban.
	ScopeGlobal = "global"
)

Comment bans hold current state only: who is banned, in which scope, why and until when; no history. A ban stops every new comment by its user in its scope (top-level, reply and edit, on anyone's content including their own) and hides or deletes nothing. Reactions, favorites and votes are not banned; the rate limits cover them.

View Source
const ScopeIP = "ip"

ScopeIP is the BanNotice scope of an IP ban.

Variables

View Source
var (
	// ErrNotFound: the reference does not exist. -> 404
	ErrNotFound = errors.New("content: not found")
	// ErrNotVisible: exists but unpublished or soft-deleted. -> 404 (hidden).
	ErrNotVisible = errors.New("content: not visible")
	// ErrForbidden: visible but the actor may not consume it. -> 403
	ErrForbidden = errors.New("content: not accessible")
	// ErrTenant: a reference of another tenant reached this runtime.
	ErrTenant = errors.New("content: reference belongs to another tenant")
	// ErrCommentsClosed: the target's comments are closed or hidden. -> 403
	// comments_closed
	ErrCommentsClosed = errors.New("content: comments are closed")
	// ErrNoClassifier: a free-text poll needs an AnswerClassifier. -> 501
	ErrNoClassifier = errors.New("content: free-text polls need an AnswerClassifier")
)

Sentinel errors a resolver may return to gate a target; mapped to HTTP status without leaking which one beyond the code.

View Source
var (
	ErrInvalid  = errors.New("content: invalid import")
	ErrConflict = errors.New("content: import conflict")
)

Errors of the bulk import's writes; match with errors.Is.

View Source
var ErrCommentBanned = errors.New("content: banned from commenting")

ErrCommentBanned refuses a comment write by a banned user. -> 403 comment_banned

View Source
var ErrRateLimited = errors.New("content: rate limited")

ErrRateLimited is a refusal a later attempt passes. -> 429 rate_limited

View Source
var ErrSubjectErased = fmt.Errorf("content: subject erased")

ErrSubjectErased is returned when an erased account tries to write again.

View Source
var RateLimitRedisErrors = expvar.NewInt("contentkit_content_ratelimit_redis_errors")

RateLimitRedisErrors counts shared-limit Redis failures, each served by the per-process count; published as expvar "contentkit_content_ratelimit_redis_errors".

TargetTypes are the route families interactions are served under.

Functions

func GroupScope added in v0.83.0

func GroupScope(groupID string) string

GroupScope is the scope of a user-owned group and the content it owns (Resolution.OwnerGroup), managed by the group's comment moderators.

func OwnerScope added in v0.63.0

func OwnerScope(ownerID string) string

OwnerScope is the scope of content whose Resolution.Owner is ownerID, managed by that owner.

func RatingSQL added in v0.83.0

func RatingSQL(likes, dislikes string) string

RatingSQL is the Wilson lower bound (95%) of likes among likes and dislikes: the "best" order of comments and the rating of content. Its arguments are trusted column expressions, never user input.

Types

type Action added in v0.63.0

type Action string

Action names one rate-limited interaction.

const (
	ActionComment         Action = "comment"          // comment create, top-level or reply
	ActionCommentReaction Action = "comment_reaction" // like, dislike, neutral on a comment
	ActionReaction        Action = "reaction"         // like, dislike, neutral on content
	ActionFavorite        Action = "favorite"         // favorite and unfavorite
	ActionPollVote        Action = "poll_vote"        // multiple-choice votes and free-text answers
)

type AdminComment

type AdminComment struct {
	Comment
	contentref.ContentRef
	DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

AdminComment is the moderation view: raw body (no tombstoning), deletion and moderation state and the content reference.

type Anonymous added in v0.68.0

type Anonymous struct {
	// Comments: comment and reply under a name (anon_name).
	Comments bool `json:"comments"`
	// Reactions: like and dislike works, posts and comments, keyed by IP.
	Reactions bool `json:"reactions"`
	// Votes: vote in multiple-choice polls, keyed by IP. Free-text answers
	// always need a signed-in actor.
	Votes bool `json:"votes"`
}

Anonymous is what signed-out visitors may do. The zero value allows none of it: an anonymous attempt answers 401 unauthorized.

type Answer

type Answer struct {
	Tenant     string
	QuestionID string
	AnswerID   string
	Revision   int64  // monotonic per answer; retries retain this revision
	SubjectID  string // opaque authenticated actor, never an IP
	Text       string
}

Answer is one free-text poll answer handed to the AnswerClassifier.

type AnswerClassifier

type AnswerClassifier interface {
	Classify(ctx context.Context, a Answer) (GroupAssignment, error)
}

AnswerClassifier groups free-text poll answers. Classify runs when an answer is stored or edited. Poll results come from source assignments. Without a registered classifier a free-text poll cannot be created. Classify must be idempotent by (Tenant, AnswerID, Revision), ignore older revisions. ContentKit accepts assignments only by source-revision CAS; the stored result is the sole authority for current membership and labels. A classifier retaining personal data needs Options.ProviderDataEraser.

type AnswerGroup added in v0.83.0

type AnswerGroup struct {
	ID    string `json:"id"`
	Label string `json:"label"`
	Count int    `json:"count"`
}

AnswerGroup is one answer group of a free-text poll with its current size. The ContentKit owns current assignments and counts, so delayed provider side effects cannot rewrite results.

type AuthError added in v0.71.0

type AuthError struct{ Err error }

AuthError is a permission check the host's auth could not answer: 401 unauthorized when the credential ended since the request was verified (auth.ErrExpired, auth.ErrRevoked), else 503 unavailable. Never a grant.

func (*AuthError) Error added in v0.71.0

func (e *AuthError) Error() string

func (*AuthError) Unwrap added in v0.71.0

func (e *AuthError) Unwrap() error

type AuthorReactions

type AuthorReactions struct {
	Likes    int64 `json:"likes"`
	Dislikes int64 `json:"dislikes"`
}

AuthorReactions is one author's received like/dislike totals across their published comments.

type BanInput added in v0.68.0

type BanInput struct {
	Reason string     `json:"reason,omitempty"`
	Until  *time.Time `json:"until"`
}

BanInput is the ban body; both fields are optional.

type BanNotice added in v0.63.0

type BanNotice struct {
	Scope  string     `json:"scope"`
	Reason string     `json:"reason,omitempty"`
	Until  *time.Time `json:"until,omitempty"`
}

BanNotice is what a banned user is told: the scope, the reason and when the ban ends (nil: when lifted). It never names who banned them.

type BanScope added in v0.68.0

type BanScope string

BanScope names a ban route family a caller may use on a target's commenters.

const (
	BanOwner  BanScope = "owner"  // the caller owns the target: /comment-bans
	BanGlobal BanScope = "global" // root:comments:ban: /global-comment-bans
)

type BannedError added in v0.63.0

type BannedError struct{ BanNotice }

BannedError is ErrCommentBanned with the ban that applies.

func (*BannedError) Error added in v0.63.0

func (e *BannedError) Error() string

func (*BannedError) Is added in v0.63.0

func (e *BannedError) Is(target error) bool

type BannedWord added in v0.83.0

type BannedWord struct {
	Word      string     `json:"word"`
	Action    WordAction `json:"action"`
	CreatedBy string     `json:"created_by"`
	CreatedAt time.Time  `json:"created_at"`
}

BannedWord is one banned word.

type BannedWordInput added in v0.83.0

type BannedWordInput struct {
	Word   string     `json:"word"`
	Action WordAction `json:"action,omitempty"`
}

BannedWordInput adds a banned word or changes its action (mask by default).

type BasicModerator

type BasicModerator struct {
	AllowLinks bool          // default false: reject bodies containing URLs
	DupWindow  time.Duration // 0 = 30s; negative disables
	// contains filtered or unexported fields
}

BasicModerator is the deterministic policy: reject links and near-instant duplicate submissions from one actor (banned words are data: rules.go). The dup guard is per process; multi-replica hosts wanting global dedup wire their own.

func (*BasicModerator) EraseSubjects

func (m *BasicModerator) EraseSubjects(_ context.Context, tenant string, ids []string) error

func (*BasicModerator) Screen

Screen applies the rules to Title and Text. Edits skip the dup guard.

func (*BasicModerator) StatelessPolicy

func (*BasicModerator) StatelessPolicy()

type Chain

type Chain []ContentModerator

Chain runs moderators in order; the first reject or review verdict wins and an error fails the chain. Put a BasicModerator in front of an AI moderator.

func (Chain) Screen

func (c Chain) Screen(ctx context.Context, in ModerationInput) (Verdict, error)

type ClassificationPage

type ClassificationPage struct {
	Classified int
	Next       string
}

ClassificationPage reports one bounded pending scan. Resume Next even when a provider error is returned; empty Next ends the sweep. Start the next sweep from empty, so failed answers and new revisions behind the cursor are retried.

type Comment

type Comment struct {
	ID         string      `json:"id"`
	ReplyToID  string      `json:"reply_to_id,omitempty"`
	UserID     string      `json:"user_id,omitempty"`
	AnonName   string      `json:"anon_name,omitempty"`
	Language   string      `json:"language,omitempty"`
	Body       string      `json:"body"`
	Deleted    bool        `json:"deleted"`
	Likes      int         `json:"likes"`
	Dislikes   int         `json:"dislikes"`
	Mine       int16       `json:"mine"` // caller's own reaction: -1/0/1
	ReplyCount int         `json:"reply_count"`
	Author     *PublicUser `json:"author,omitempty"`
	// Moderation is "held" or "rejected" on the author's own unpublished
	// comments (with the reason); absent on published ones.
	Moderation       string    `json:"moderation,omitempty"`
	ModerationReason string    `json:"moderation_reason,omitempty"`
	CreatedAt        time.Time `json:"created_at"`
	UpdatedAt        time.Time `json:"updated_at"`
}

Comment is the API view of a content_comments row.

type CommentBan added in v0.63.0

type CommentBan struct {
	UserID   string      `json:"user_id"`
	User     *PublicUser `json:"user,omitempty"`
	Scope    string      `json:"scope"`
	Reason   string      `json:"reason,omitempty"`
	Until    *time.Time  `json:"until,omitempty"`
	BannedBy string      `json:"banned_by"`
	BannedAt time.Time   `json:"banned_at"`
	Expired  bool        `json:"expired"`
}

CommentBan is one user's ban in one scope. An expired ban stays, Expired, until it is lifted or replaced.

type CommentEdit added in v0.68.0

type CommentEdit struct {
	Body string `json:"body"`
}

CommentEdit is the body of a comment edit.

type CommentInput added in v0.68.0

type CommentInput struct {
	Body      string `json:"body"`
	ReplyToID string `json:"reply_to_id,omitempty"`
	AnonName  string `json:"anon_name,omitempty"`
	// Language is the thread the comment joins: one of the site's languages,
	// or none. A reply joins its comment's.
	Language string `json:"language,omitempty"`
}

CommentInput is the body of a new comment.

type CommentStanding added in v0.68.0

type CommentStanding struct {
	// CanComment: the caller may comment now. A signed-out caller may only
	// where Anonymous says so.
	CanComment bool `json:"can_comment"`
	// Comments is the target's comments setting: open, closed or hidden.
	Comments string     `json:"comments"`
	Ban      *BanNotice `json:"ban,omitempty"`
	// Anonymous: signed-out visitors may comment here, under a name
	// (Options.Anonymous.Comments); otherwise they are asked to sign in.
	Anonymous bool `json:"anonymous"`
	// MaxLength is the longest comment, in characters (Options.CommentMaxLength).
	MaxLength int `json:"max_length"`
	// UserID is the caller's user id, absent when anonymous: the comments it
	// wrote are the ones it may edit and delete.
	UserID string `json:"user_id,omitempty"`
	// Moderate: the caller may edit, delete and restore anyone's comments
	// (root:comments:moderate).
	Moderate bool `json:"moderate"`
	// BanScopes are the scopes the caller may ban the target's commenters in.
	BanScopes []BanScope `json:"ban_scopes"`
}

CommentStanding is the caller's standing on a target: may they comment, if a ban stops them which, and what they may do to others' comments.

type Config added in v0.68.0

type Config struct {
	Anonymous Anonymous `json:"anonymous"`
	// CommentMaxLength is the longest comment, in characters.
	CommentMaxLength int `json:"comment_max_length"`
}

Config is what the content module allows, so clients choose between a sign-in prompt and an anonymous form, and bound a comment, before acting.

type ContentModerator

type ContentModerator interface {
	Screen(ctx context.Context, in ModerationInput) (Verdict, error)
}

ContentModerator screens every comment/post write before it publishes. Absent port: every write publishes. An error or an unknown decision fails closed to review: the submission is kept, held, never published unscreened.

type ContentProcessor

type ContentProcessor interface {
	Sanitize(ctx context.Context, raw string) (string, error)
}

ContentProcessor sanitizes rich text on write. Default: strip tags.

type Counts

type Counts struct {
	Likes        int `json:"likes"`
	Dislikes     int `json:"dislikes"`
	Favorites    int `json:"favorites"`
	CommentCount int `json:"comment_count"`
}

Counts is the denormalized per-reference aggregate (content_interaction_counts).

type Decision

type Decision string

Decision is a ContentModerator's outcome for one text write.

const (
	DecisionApprove Decision = "approve" // publish
	DecisionReject  Decision = "reject"  // refuse the write: 422 with the reason, nothing stored
	DecisionReview  Decision = "review"  // store held: author-only until a reviewer resolves it
)

type FavoriteState added in v0.68.0

type FavoriteState struct {
	Favorited bool `json:"favorited"`
	Count     int  `json:"count"`
}

FavoriteState says whether the caller has favorited the target, and how many have.

type FeedItem

type FeedItem struct {
	Comment
	contentref.ContentRef
}

FeedItem is a Comment plus its canonical content reference, for the cross-content latest feed (hosts hydrate titles/covers from the reference).

type GroupAssignment

type GroupAssignment struct {
	GroupID string
	Label   string
}

GroupAssignment is the group an answer was placed in at store time.

type HeldItem

type HeldItem struct {
	Kind     string `json:"kind"`     // KindComment, or a HeldSource's kind
	Revision int64  `json:"revision"` // required when resolving this exact screened text
	ID       string `json:"id"`
	// Ref is the commented content, or the held item's own reference.
	Ref           contentref.ContentRef `json:"ref"`
	AuthorID      string                `json:"author_id,omitempty"`
	Author        *PublicUser           `json:"author,omitempty"`
	AnonName      string                `json:"anon_name,omitempty"`
	Title         string                `json:"title,omitempty"`
	Body          string                `json:"body"`
	Reason        string                `json:"reason"`
	Model         string                `json:"model,omitempty"`
	PromptVersion string                `json:"prompt_version,omitempty"`
	Confidence    float64               `json:"confidence,omitempty"`
	Error         string                `json:"error,omitempty"` // moderator failure that forced the hold
	HeldAt        time.Time             `json:"held_at"`
	CreatedAt     time.Time             `json:"created_at"`
}

HeldItem is one comment or content item awaiting review.

func (*HeldItem) SetVerdict added in v0.83.0

func (it *HeldItem) SetVerdict(meta []byte)

SetVerdict fills the screening provenance from a moderation_verdict.

type HeldPage

type HeldPage struct {
	Items []HeldItem `json:"items"`
	Next  string     `json:"next,omitempty"`
}

HeldPage is one page of the review queue; Next resumes after the last item.

type HeldSource added in v0.83.0

type HeldSource interface {
	// Held pages held items oldest first, after (after, afterID).
	Held(ctx context.Context, after time.Time, afterID string, limit int) ([]HeldItem, error)
	// ResolveHeld applies a reviewer's decision at the listed revision;
	// ErrNotFound when the item is not held at it.
	ResolveHeld(ctx context.Context, id string, d ReviewDecision) error
}

HeldSource is another module's held items in the review queue: the catalog's content items, under catalog.HeldKind.

type IPBan added in v0.83.0

type IPBan struct {
	Network  string     `json:"network"`
	Reason   string     `json:"reason,omitempty"`
	Until    *time.Time `json:"until,omitempty"`
	BannedBy string     `json:"banned_by"`
	BannedAt time.Time  `json:"banned_at"`
	Expired  bool       `json:"expired"`
}

IPBan refuses comments and answers from an address or network.

type IPBanInput added in v0.83.0

type IPBanInput struct {
	Network string     `json:"network"`
	Reason  string     `json:"reason,omitempty"`
	Until   *time.Time `json:"until,omitempty"`
}

IPBanInput bans an address ("203.0.113.7") or a network ("203.0.113.0/24"), or replaces its ban.

type ImageInput added in v0.68.0

type ImageInput struct {
	Image *string `json:"image"`
}

ImageInput names an inline image of the item's media folder ("i-{uuid}"); "" clears where the route allows it.

type ImportAnswer added in v0.83.0

type ImportAnswer struct {
	PollID    string
	UserID    string
	Text      string
	CreatedAt *time.Time
	UpdatedAt *time.Time
}

ImportAnswer is one person's free-text answer, classified later.

type ImportComment added in v0.83.0

type ImportComment struct {
	ID        string
	Ref       contentref.ContentRef
	Language  string
	ReplyToID string
	UserID    string
	AnonName  string
	Body      string
	DeletedAt *time.Time
	CreatedAt *time.Time
	UpdatedAt *time.Time
}

ImportComment is a comment as the bulk import writes it: approved, its id, thread and times kept. ReplyToID names a comment on the same reference; a reply to a reply joins its thread.

type ImportFavorite added in v0.83.0

type ImportFavorite struct {
	Ref       contentref.ContentRef
	UserID    string
	CreatedAt *time.Time
}

ImportFavorite is a person's bookmark.

type ImportPoll added in v0.83.0

type ImportPoll struct {
	ID        string
	Kind      string
	Question  string
	Language  string
	Active    bool
	LiveAt    *time.Time
	ClosesAt  *time.Time
	DeletedAt *time.Time
	CreatedAt *time.Time
	UpdatedAt *time.Time
	Options   []ImportPollOption
}

ImportPoll is a poll with its options, which replace the stored ones (and their votes).

type ImportPollOption added in v0.83.0

type ImportPollOption struct {
	ID       string `json:"id"`
	Label    string `json:"label"`
	Position int    `json:"position"`
}

ImportPollOption is one option of an ImportPoll.

type ImportReaction added in v0.83.0

type ImportReaction struct {
	Ref       contentref.ContentRef
	UserID    string
	IP        string
	Value     int16
	CreatedAt *time.Time
	UpdatedAt *time.Time
}

ImportReaction is a person's (or an IP's) reaction: -1, 0 or 1.

type ImportVote added in v0.83.0

type ImportVote struct {
	PollID    string
	OptionID  string
	UserID    string
	IP        string
	CreatedAt *time.Time
}

ImportVote is one person's (or IP's) choice in a multiple-choice poll.

type Limits added in v0.63.0

type Limits struct {
	Comment         []Rate // default 5 per 5 minutes and 20 per hour
	CommentReaction []Rate // default 30 per minute
	Reaction        []Rate // default 30 per minute
	Favorite        []Rate // default 20 per minute
	PollVote        []Rate // default 30 per minute

	// Redis (or Garnet) shares the limits across replicas; pass the host's
	// client. Without it each process counts alone, so N replicas allow N
	// times the limit. Redis errors fall back to the per-process count.
	Redis     redis.UniversalClient
	KeyPrefix string // default "contentkit:content:rl:"; the tenant follows it
	// Disabled turns every limit off.
	Disabled bool
}

Limits are per-actor interaction limits: an actor is its user id, or its IP when anonymous. Every attempt spends one, so undoing (unfavorite, neutral) costs the same as doing. A nil field takes its default; an action with several rates must pass all of them.

type Media added in v0.17.0

type Media struct {
	Images  MediaImages  // *media.Manifests
	Folders MediaFolders // *media.Jobs
	// Reader reads items as the media read API does; the engine fills it
	// from its Reader.
	Reader   MediaReader
	PollKind string // media kind of poll folders; default "poll"
}

Media connects poll images, and authors' avatars, to ContentKit media. The browser uploads an image to a Named upload path of the poll's item (the host's media registry declares it and its public preset), and hands its name ("i-{uuid}") to ContentKit, which stores it. Reads resolve names to the images' current public files, one MediaImages lookup per response; poll deletion removes the item. Runtime.CanUpload authorizes those uploads.

type MediaFolders added in v0.17.0

type MediaFolders interface {
	ExposeTx(ctx context.Context, tx pgx.Tx, refs ...contentref.ContentRef) error
	DeleteItemsTx(ctx context.Context, tx pgx.Tx, items ...media.Deletion) error
}

MediaFolders queues visibility changes and folder deletions in the content transaction; *media.Jobs implements it.

type MediaImages added in v0.70.0

type MediaImages interface {
	Images(ctx context.Context, qs ...media.ImageQuery) ([][]media.PublicImage, error)
	Avatars(ctx context.Context, ids ...string) ([]media.PublicImage, error)
}

MediaImages answers image queries from the current publications in one read, and people's avatars (media.Config.Avatars) in another; *media.Manifests implements it.

type MediaReader added in v0.70.0

type MediaReader interface {
	Read(ctx context.Context, ref contentref.ContentRef, actor access.Actor, o media.ReadOptions) (*media.ReadResult, error)
}

MediaReader reads an item as the media read API does; *media.Reader implements it.

type ModerationInput

type ModerationInput struct {
	SubjectID string // opaque content author; may differ from editing moderator
	Tenant    string
	Actor     access.Actor
	// Ref is the content the item belongs to: the commented content for a
	// comment, the item's own reference for catalog text.
	Ref    contentref.ContentRef
	Kind   string // KindComment, or a HeldSource's kind (catalog.HeldKind)
	ItemID string // the existing item on an edit; empty on create
	Title  string // catalog text only
	Text   string
}

ModerationInput is one comment, or a catalog item's text, about to publish. The moderator sees sanitized text and opaque ids only.

type Nullable added in v0.68.0

type Nullable[T any] struct {
	Set   bool
	Value *T
}

Nullable is a PATCH member that tells absent (leave it) from null (clear it): Set reports the member was present, Value is nil for null. Tag it omitzero, so an unset one is left out when encoded.

func (Nullable[T]) IsZero added in v0.68.0

func (n Nullable[T]) IsZero() bool

func (Nullable[T]) MarshalJSON added in v0.68.0

func (n Nullable[T]) MarshalJSON() ([]byte, error)

func (*Nullable[T]) UnmarshalJSON added in v0.68.0

func (n *Nullable[T]) UnmarshalJSON(b []byte) error

type Options

type Options struct {
	// Pool is the host's shared pgx pool; the runtime does not own its lifecycle.
	Pool *pgxpool.Pool
	// Schema is ContentKit's schema.
	Schema string
	// Tenant scopes every row, index, cursor and result. Required.
	Tenant string

	// Auth says who a request is (authkit/auth; AuthKit's ak.Authenticator()).
	// The routes ask it at most once per request and build the actor from
	// its Verified: anonymous without a credential, 401 for one that fails.
	// Staff hold ContentKit's permissions (access.Permissions) in its root
	// scope (auth.Root), checked by the Verified's Can: declare them in its
	// roles. Required.
	Auth auth.Authenticator
	// UserInfo is the host's directory (authkit/userinfo; AuthKit's
	// ak.UserInfo()): authors' names and usernames, read once per response
	// and never copied. Nil shows authors by id.
	UserInfo userinfo.Lookup
	// Resolver decides interaction targets: content items and their versions
	// (the catalog's) and groups. Required.
	Resolver resolve.Resolver
	// Kinds is the site's content model: interactions target its content
	// kinds and group kinds, and comments carry one of its languages.
	Kinds kinds.Config

	// PreferenceSyncOverlap bounds how long a preference write may take to
	// commit and still be picked up by SyncPreferences.
	// Default DefaultPreferenceSyncOverlap.
	PreferenceSyncOverlap time.Duration

	// Optional ports (nil -> default).
	Processor ContentProcessor // comments; default: strip tags
	// Moderator screens comment writes, and the catalog's (Screen); nil
	// publishes everything.
	// Compose a BasicModerator in front of an AI moderator with Chain.
	Moderator ContentModerator
	// ModeratorTimeout bounds one Screen call (default 5s); a slower
	// moderator counts as failed and the write is held.
	ModeratorTimeout time.Duration
	// ModeratorCooldown is how long writes are held without calling the
	// moderator after it failed repeatedly (default 30s); see CheckModerator.
	ModeratorCooldown time.Duration
	// Classifier groups free-text poll answers; nil refuses free-text polls.
	Classifier AnswerClassifier

	// Media stores poll images in ContentKit media; nil refuses image writes
	// with 501 not_configured. See Media.
	Media *Media

	// ProviderDataEraser is required when moderator/classifier ports retain
	// external personal data.
	// Nil explicitly means stateless ports; never remove it during an outage.
	ProviderDataEraser ProviderDataEraser

	// Limits are the per-actor interaction rate limits (limits.go); the zero
	// value takes the defaults, counted per process.
	Limits Limits

	// Anonymous is what signed-out visitors may do; the zero value lets them
	// only read.
	Anonymous Anonymous
	// CommentMaxLength is the longest comment in characters (runes);
	// default DefaultCommentMaxLength.
	CommentMaxLength int

	// Logger receives the access log: each request at DEBUG, a 500 at ERROR
	// with its cause. nil -> slog.Default().
	Logger *slog.Logger
}

Options configures a Runtime. Pool, Schema, Tenant, Auth and Resolver are mandatory; the rest fall back to documented defaults.

type Poll added in v0.68.0

type Poll struct {
	ID       string `json:"id"`
	Kind     string `json:"kind"`
	Question string `json:"question"`
	Language string `json:"language"`
	IsActive bool   `json:"is_active"`
	ImageURL string `json:"image_url,omitempty"`

	LiveAt     time.Time    `json:"live_at"`
	ClosesAt   *time.Time   `json:"closes_at,omitempty"`
	Closed     bool         `json:"closed"` // inactive or past closes_at: no more votes/answers
	TotalVotes int          `json:"total_votes"`
	Options    []PollOption `json:"options"`
	Voted      bool         `json:"voted"`
	MyOption   string       `json:"my_option,omitempty"`
	// free_text only
	AnswerCount int           `json:"answer_count,omitempty"`
	Groups      []AnswerGroup `json:"groups,omitempty"`
	MyAnswer    *PollAnswer   `json:"my_answer,omitempty"`
	// contains filtered or unexported fields
}

Poll is a question plus its options and the caller's own vote (if any); a free_text poll carries its answer groups and the caller's answer.

type PollAnswer added in v0.68.0

type PollAnswer struct {
	ID         string    `json:"id"`
	Text       string    `json:"text"`
	Classified bool      `json:"classified"`
	UpdatedAt  time.Time `json:"updated_at"`
}

PollAnswer is the caller's own free-text answer.

type PollAnswerInput added in v0.68.0

type PollAnswerInput struct {
	Text string `json:"text"`
}

PollAnswerInput is a free-text answer.

type PollImage added in v0.68.0

type PollImage struct {
	ImageURL *string `json:"image_url"`
}

PollImage is the image's public URL after an image change; null when cleared or while it has no public file.

type PollInput added in v0.68.0

type PollInput struct {
	Kind     string            `json:"kind,omitempty"` // default multiple_choice
	Question string            `json:"question"`
	Language string            `json:"language"`
	LiveAt   *time.Time        `json:"live_at,omitempty"`   // nil = live now
	ClosesAt *time.Time        `json:"closes_at,omitempty"` // nil = open until deactivated
	Options  []PollOptionInput `json:"options,omitempty"`
}

PollInput is a new poll.

type PollOption added in v0.68.0

type PollOption struct {
	ID        string `json:"id"`
	Label     string `json:"label"`
	ImageURL  string `json:"image_url,omitempty"`
	Position  int    `json:"position"`
	VoteCount int    `json:"vote_count"`
	// contains filtered or unexported fields
}

PollOption is one choice with its denormalized vote_count.

type PollOptionInput added in v0.68.0

type PollOptionInput struct {
	Label    string `json:"label"`
	Position int    `json:"position"`
}

PollOptionInput is one option of a new poll. Images are set once the poll exists (PUT /polls/{id}/options/{oid}/image), since they live in its folder.

type PollOptionPatch added in v0.68.0

type PollOptionPatch struct {
	Label    *string `json:"label"`
	Position *int    `json:"position"`
}

PollOptionPatch is the option add and update body; pointers so an absent field is untouched.

type PollUpdate added in v0.68.0

type PollUpdate struct {
	Question *string             `json:"question"`
	IsActive *bool               `json:"is_active"`
	LiveAt   *time.Time          `json:"live_at"`
	ClosesAt Nullable[time.Time] `json:"closes_at,omitzero"`
}

PollUpdate leaves absent fields untouched; closes_at null clears it (open until deactivated).

type PollVote added in v0.68.0

type PollVote struct {
	OptionID string `json:"option_id"`
}

PollVote is a vote for one option.

type Preference added in v0.15.0

type Preference struct {
	contentref.ContentRef
	ActorID   string
	Axis      string
	Value     int16
	Revision  int64
	UpdatedAt time.Time
}

Preference is one exported row: an actor's current value on one axis of a canonical reference, with the revision and time of its last change.

type PreferenceSender added in v0.15.0

type PreferenceSender func(ctx context.Context, page []Preference) error

PreferenceSender durably writes one page of preferences; an error aborts the sync without advancing the watermark. Re-sends must be idempotent.

type PreferenceSyncReport added in v0.15.0

type PreferenceSyncReport struct {
	From int64
	Sent int
}

PreferenceSyncReport reports one sync: the revision it scanned after and the rows it sent.

type ProviderDataEraser added in v0.15.0

type ProviderDataEraser interface {
	EraseSubjects(ctx context.Context, tenant string, actorIDs []string) error
}

ProviderDataEraser deletes personal data retained by optional moderator and classifier implementations. Success means a durable tenant/subject fence is installed: Classify/Screen calls begun before erasure cannot recreate data when they complete later. Merely queuing a delete is not success. Calls are idempotent; implementations must preserve fences across restore/restart. A host using multiple retaining providers must implement a composite eraser.

type PublicUser

type PublicUser struct {
	ID           string `json:"id"`
	Name         string `json:"name,omitempty"`
	Username     string `json:"username"`
	Avatar       string `json:"avatar,omitempty"`
	AvatarSrcSet string `json:"avatar_srcset,omitempty"`
}

PublicUser is how an author is shown: its name and username from the host's directory (Options.UserInfo) and its avatar from ContentKit media (media.Config.Avatars). Never its email.

type Rate added in v0.63.0

type Rate struct {
	Count int
	Per   time.Duration
}

Rate allows at most Count interactions in any Per (a sliding window).

type RateLimitError added in v0.63.0

type RateLimitError struct {
	Action     Action
	RetryAfter time.Duration
}

RateLimitError is ErrRateLimited for one action, with how long to wait.

func (*RateLimitError) Error added in v0.63.0

func (e *RateLimitError) Error() string

func (*RateLimitError) Is added in v0.63.0

func (e *RateLimitError) Is(target error) bool

type ReactionCounts added in v0.68.0

type ReactionCounts struct {
	Likes    int   `json:"likes"`
	Dislikes int   `json:"dislikes"`
	Mine     int16 `json:"mine"` // -1, 0, or 1; 0 also means "no reaction"
}

ReactionCounts is the split tally for a reference plus the caller's own value.

type RejectedError

type RejectedError struct{ Reason string }

RejectedError is a policy rejection of a text write, answered as 422 with its reason: a ContentModerator's reject verdict.

func (RejectedError) Error

func (e RejectedError) Error() string

type ResolveInput added in v0.68.0

type ResolveInput struct {
	Decision Decision `json:"decision"`
	Revision int64    `json:"revision"`
	Reason   string   `json:"reason,omitempty"`
}

ResolveInput resolves a held item at the revision the queue listed; Reason replaces the moderator's on a rejection.

type Restored added in v0.68.0

type Restored struct {
	Restored bool `json:"restored"`
}

Restored confirms a comment's restoration.

type ReviewDecision

type ReviewDecision struct {
	Revision int64 // the revision returned by ListHeld; stale decisions cannot publish edits
	Decision Decision
	Reviewer string
	Reason   string
}

ReviewDecision resolves a held item: approve publishes it, reject keeps it author-only with Reason (or the moderator's reason when empty).

type ReviewOutcome added in v0.68.0

type ReviewOutcome struct {
	Decision Decision `json:"decision"`
}

ReviewOutcome is the decision applied.

type Runtime

type Runtime struct {
	// Notifications is this tenant's user inbox.
	Notifications *notification.Store
	// contains filtered or unexported fields
}

Runtime is one tenant's embedded content module: shared deps + the module services, exposing one mountable http.Handler.

func New

func New(ctx context.Context, opts Options) (*Runtime, error)

New constructs a Runtime over a schema the ContentKit baseline was applied to and wires the module services.

func (*Runtime) AddHeldSource added in v0.83.0

func (rt *Runtime) AddHeldSource(kind string, src HeldSource) error

AddHeldSource puts src's held items in the review queue under kind. Call it before serving.

func (*Runtime) CanUpload added in v0.17.0

func (rt *Runtime) CanUpload(ctx context.Context, actor access.Actor, t media.UploadTarget) (media.UploadGrant, error)

CanUpload is media's UploadAuthorizer for poll items: the actor holds root:polls:write and the poll exists in this tenant. Every other target is refused; other kinds are routed elsewhere.

func (*Runtime) CheckModerator added in v0.58.0

func (rt *Runtime) CheckModerator(context.Context) error

CheckModerator reports the moderator's state without calling it: an error while repeated failures hold writes for review. Hosts register it as an optional dependency (helpers deps) for statusz and app_dependency_up.

func (*Runtime) CommentReactionsByAuthor

func (rt *Runtime) CommentReactionsByAuthor(ctx context.Context, userIDs []string) (map[string]AuthorReactions, error)

CommentReactionsByAuthor totals the reactions each author's published comments have received. Held and rejected comments are author-only and never counted, so every reader sees the same totals. An author with no published comments is absent from the map.

func (*Runtime) Counts

func (rt *Runtime) Counts(ctx context.Context, refs []contentref.ContentRef) (map[contentref.ContentKey]Counts, error)

Counts batch-reads the aggregate counts of refs (O(1) rollup rows). A reference with no engagement yet is absent from the map.

func (*Runtime) EraseSubjects

func (rt *Runtime) EraseSubjects(ctx context.Context, actorIDs []string) error

EraseSubjects first erases and fences the user inbox, then fences future authenticated content writes, removes reactions, favorites, poll votes/answers and comment bans of or by them atomically, and removes unpublished held/rejected comment payloads and moderation provenance. A previously published comment retains its last approved payload without publishing it again; its unpublished replacement is erased. Never-published comments become tombstones. Published content and reply structure stay under host policy.

The source transaction commits BEFORE calling the external eraser. A provider failure must keep the HOST's downstream erasure obligation pending for retry. AuthKit acknowledgement means durable LOCAL acceptance of that obligation; do not delay that acknowledgement until this method or remote cleanup succeeds. Nil ProviderDataEraser means configured policy ports retain no external personal data. Retaining providers must configure an eraser, including while offline.

func (*Runtime) Handler

func (rt *Runtime) Handler() http.Handler

Handler returns the content routes, each behind its tier's gate over Options.Auth: the engine's content route group (/v1). Each request runs in an access.WithMemo context, so a Gate resolver reads billing at most once per request.

func (*Runtime) ImportAnswers added in v0.83.0

func (rt *Runtime) ImportAnswers(ctx context.Context, tx pgx.Tx, in []ImportAnswer) error

ImportAnswers writes free-text answers in tx (one per person per poll); a changed text awaits the classifier again.

func (*Runtime) ImportComments added in v0.83.0

func (rt *Runtime) ImportComments(ctx context.Context, tx pgx.Tx, in []ImportComment) error

ImportComments writes comments in tx, inserting new ones and updating stored ones whose import changed (a moderator's decision stays), then recounts their references' comment counts and their threads' replies.

func (*Runtime) ImportFavorites added in v0.83.0

func (rt *Runtime) ImportFavorites(ctx context.Context, tx pgx.Tx, in []ImportFavorite) error

ImportFavorites writes bookmarks in tx: a new one takes a fresh preference revision. Counts follow.

func (*Runtime) ImportPolls added in v0.83.0

func (rt *Runtime) ImportPolls(ctx context.Context, tx pgx.Tx, in []ImportPoll) error

ImportPolls writes polls and their options in tx; options the import no longer lists go, with their votes.

func (*Runtime) ImportReactions added in v0.83.0

func (rt *Runtime) ImportReactions(ctx context.Context, tx pgx.Tx, in []ImportReaction) error

ImportReactions writes reactions in tx: a new value takes a fresh preference revision, so preference sync exports it. Counts follow.

func (*Runtime) ImportVotes added in v0.83.0

func (rt *Runtime) ImportVotes(ctx context.Context, tx pgx.Tx, in []ImportVote) error

ImportVotes writes votes in tx (one per person, or IP, per poll; the last wins) and recounts their polls' options.

func (*Runtime) LatestComments

func (rt *Runtime) LatestComments(ctx context.Context, actor access.Actor, language string, limit, offset int) ([]FeedItem, error)

LatestComments is the feed: newest comments across all content the actor may see, in language ("" for every language).

func (*Runtime) LatestCommentsTotal

func (rt *Runtime) LatestCommentsTotal(ctx context.Context) (int, error)

LatestCommentsTotal is the page total for LatestComments: how many live, approved comments the feed draws from in this tenant. Per-reference visibility is applied per page (a page may under-fill), so this is the upper bound a paged envelope reports, not a per-actor exact count.

func (*Runtime) ListHeld

func (rt *Runtime) ListHeld(ctx context.Context, kind, cursor string, limit int) (HeldPage, error)

ListHeld pages this tenant's held comments, or a held source's items, oldest first; cursor is the previous page's Next ("" = start). limit is clamped to [1, 100].

func (*Runtime) MediaResolver added in v0.68.0

func (rt *Runtime) MediaResolver() resolve.Resolver

MediaResolver is the resolve.Resolver of the poll media kind: route the host registry's Hooks.Resolver to it for Media.PollKind, as CanUpload is routed. A poll's folder shows while the poll exists, edited by root:polls:write holders. Other refs are omitted (denied).

func (*Runtime) RaisePreferenceFloor added in v0.83.0

func (rt *Runtime) RaisePreferenceFloor(ctx context.Context, tx pgx.Tx, floor int64) error

RaisePreferenceFloor advances the preference revision sequence so every later revision exceeds floor: after a Postgres restore, past the newest revision a retained signal plane holds. It never moves it back and fails outside the safe range.

func (*Runtime) ReclassifyPending

func (rt *Runtime) ReclassifyPending(ctx context.Context, after string, limit int) (ClassificationPage, error)

ReclassifyPending processes one page in immutable answer-id order. The cursor is per-sweep only, never a persistent high-water mark. Errors do not strand later keys. Unchanged source revisions retain their classification identity.

func (*Runtime) Ref

func (rt *Runtime) Ref(contentKind, contentID string) contentref.ContentRef

Ref returns a reference to a work of this runtime's tenant.

func (*Runtime) Resolve

func (rt *Runtime) Resolve(ctx context.Context, kind, id string, d ReviewDecision) error

Resolve writes a reviewer's final decision on a held item. Approve publishes it (counts follow, and a held source's own effects); reject keeps it author-visible with the reason. A missing, foreign-tenant or not-held item is ErrNotFound.

func (*Runtime) ResyncPreferences added in v0.15.0

func (rt *Runtime) ResyncPreferences(ctx context.Context, send PreferenceSender) (PreferenceSyncReport, error)

ResyncPreferences re-sends every exportable row (zeros included): the periodic safety net for sink loss and slower-than-overlap commits. A sender is required.

func (*Runtime) Screen added in v0.83.0

func (rt *Runtime) Screen(ctx context.Context, in ModerationInput) (Screening, error)

Screen runs the moderator over another module's text write (the catalog's) as it does over comments: a reject verdict is a RejectedError (store nothing), a failure or an unknown decision holds, no moderator approves. An erased subject is refused (ErrSubjectErased).

func (*Runtime) SyncPreferences added in v0.15.0

func (rt *Runtime) SyncPreferences(ctx context.Context, send PreferenceSender) (PreferenceSyncReport, error)

SyncPreferences sends every exportable row whose revision is past the watermark, in revision order, then records a checkpoint. No connection is held across send. Revisions are allocated before commit, so each scan restarts from the newest checkpoint taken at least Options.PreferenceSyncOverlap before the latest one: a row is delivered as long as its transaction commits within the overlap of allocating its revision (ResyncPreferences covers the rest). A checkpoint is recorded only after its own scan was sent, so overlapping syncs stay correct and only repeat sends. send is required.

func (*Runtime) Tenant

func (rt *Runtime) Tenant() string

Tenant returns the tenant this runtime is scoped to.

type Screening added in v0.83.0

type Screening struct {
	State   string
	Reason  *string
	Verdict []byte
}

Screening is what a write stores after the moderator ran: State approved or held, with the Reason shown to the author and the Verdict kept for the reviewer (moderation_verdict jsonb, nil without a moderator).

type StatelessPolicy

type StatelessPolicy interface{ StatelessPolicy() }

StatelessPolicy explicitly declares that a policy implementation retains no personal data outside this process. Built-in deterministic policies satisfy this marker; retaining providers must instead configure ProviderDataEraser.

type TargetType added in v0.83.0

type TargetType string

TargetType is what an interaction route names: a content item (optionally one of its versions) or a group. Rows store the target under its kind from kinds.Config and its id.

const (
	TargetContent TargetType = "contents"
	TargetGroup   TargetType = "groups"
)

type TooLongError added in v0.68.0

type TooLongError struct{ Max int }

TooLongError refuses a comment longer than Max characters. -> 422 comment_too_long

func (*TooLongError) Error added in v0.68.0

func (e *TooLongError) Error() string

type Verdict

type Verdict struct {
	Decision      Decision
	Reason        string
	Model         string
	PromptVersion string
	Confidence    float64
}

Verdict is a moderator's decision with its provenance. Reason is shown to the author on reject and review; Model, PromptVersion and Confidence are kept with a held item for the reviewer.

type WordAction added in v0.83.0

type WordAction string

WordAction is what a banned word does to text containing it.

const (
	WordMask   WordAction = "mask"   // replaced by asterisks
	WordReject WordAction = "reject" // the write is refused
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL