Documentation
¶
Overview ¶
Package content is ContentKit's interaction module: posts, 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
- Variables
- func ImageRef(name string) string
- func OwnerScope(ownerID string) string
- type Action
- type ActorReaction
- type AdminComment
- type Anonymous
- type Answer
- type AnswerClassifier
- type AuthorReactions
- type Authorizer
- type BanInput
- type BanNotice
- type BanScope
- type BannedError
- type BasicModerator
- type Chain
- type ClassificationPage
- type Comment
- type CommentBan
- type CommentEdit
- type CommentInput
- type CommentStanding
- type Config
- type ContentCanonicalizer
- type ContentCanonicalizerFunc
- type ContentModerator
- type ContentProcessor
- type Counts
- type Decision
- type FavoriteItem
- type FavoriteState
- type FeedItem
- type Group
- type GroupAssignment
- type HeldItem
- type HeldPage
- type Identity
- type ImageInput
- type InlineImage
- type Limits
- type Media
- type MediaFolders
- type MediaImages
- type MediaReader
- type ModerationInput
- type Nullable
- type Options
- type Perms
- type Poll
- type PollAnswer
- type PollAnswerInput
- type PollImage
- type PollInput
- type PollOption
- type PollOptionInput
- type PollOptionPatch
- type PollUpdate
- type PollVote
- type Post
- type PostCover
- type PostInput
- type Preference
- type PreferenceSender
- type PreferenceSyncReport
- type ProviderDataEraser
- type PublicUser
- type Rate
- type RateLimitError
- type ReactionCounts
- type RejectedError
- type ResolveInput
- type Restored
- type ReviewDecision
- type ReviewOutcome
- type Runtime
- func (rt *Runtime) CanUpload(ctx context.Context, actor access.Actor, t media.UploadTarget) (media.UploadGrant, error)
- func (rt *Runtime) CheckModerator(context.Context) error
- func (rt *Runtime) CommentReactionsByAuthor(ctx context.Context, userIDs []string) (map[string]AuthorReactions, error)
- func (rt *Runtime) Counts(ctx context.Context, refs []contentref.ContentRef) (map[contentref.ContentKey]Counts, error)
- func (rt *Runtime) EraseSubjects(ctx context.Context, actorIDs []string) error
- func (rt *Runtime) Guard(perm string, next http.Handler) http.Handler
- func (rt *Runtime) Handler() http.Handler
- func (rt *Runtime) IsFavorited(ctx context.Context, userID string, refs []contentref.ContentRef) (map[contentref.ContentKey]bool, error)
- func (rt *Runtime) KeywordDocuments(ctx context.Context, tenant, kind, language string, ...) ([]search.KeywordDocument, error)
- func (rt *Runtime) LatestComments(ctx context.Context, actor access.Actor, limit, offset int) ([]FeedItem, error)
- func (rt *Runtime) LatestCommentsTotal(ctx context.Context) (int, error)
- func (rt *Runtime) ListContent(ctx context.Context, tenant, kind, language, cursor string, limit int) ([]contentref.ContentRef, string, bool, error)
- func (rt *Runtime) ListFavorites(ctx context.Context, userID string, limit, offset int) ([]FavoriteItem, error)
- func (rt *Runtime) ListHeld(ctx context.Context, kind, cursor string, limit int) (HeldPage, error)
- func (rt *Runtime) MediaResolver() access.ContentResolver
- func (rt *Runtime) MyReactions(ctx context.Context, actor access.Actor, refs []contentref.ContentRef) (map[contentref.ContentKey]int16, error)
- func (rt *Runtime) PostVisibility(ctx context.Context, id string) (contenturl.Visibility, error)
- func (rt *Runtime) ReactionsByActor(ctx context.Context, actor access.Actor, kind string, limit, offset int) ([]ActorReaction, error)
- func (rt *Runtime) ReclassifyPending(ctx context.Context, after string, limit int) (ClassificationPage, error)
- func (rt *Runtime) Ref(contentKind, contentID string) contentref.ContentRef
- func (rt *Runtime) Resolve(ctx context.Context, kind, id string, d ReviewDecision) error
- func (rt *Runtime) ResolvePosts(ctx context.Context, posts []Post) error
- func (rt *Runtime) ResyncPreferences(ctx context.Context, send PreferenceSender) (PreferenceSyncReport, error)
- func (rt *Runtime) SeedPreferenceRevisionFloor(ctx context.Context, floor int64) (int64, error)
- func (rt *Runtime) SyncPreferences(ctx context.Context, send PreferenceSender) (PreferenceSyncReport, error)
- func (rt *Runtime) Tenant() string
- type StatelessPolicy
- type TooLongError
- type UserEnricher
- type Verdict
Constants ¶
const ( CodeInvalidRequest = "invalid_request" 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" // 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" 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.
const ( ModerationApproved = "approved" ModerationHeld = "held" ModerationRejected = "rejected" )
Moderation states stored in content_comments.moderation / content_posts.moderation.
const ( PollMultipleChoice = "multiple_choice" PollFreeText = "free_text" )
Poll kinds.
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.
const ( KindComment = "comment" KindPost = "post" )
Reserved content kinds owned by this package: reactions on comments and posts are keyed by them and never pass through the access.ContentResolver.
const DefaultCommentMaxLength = 2200
DefaultCommentMaxLength is Options.CommentMaxLength unset.
const DefaultPreferenceSyncOverlap = 5 * time.Minute
DefaultPreferenceSyncOverlap is the default Options.PreferenceSyncOverlap.
const ImageScheme = "contentkit:"
ImageScheme is the URL scheme of an image reference. A host's ContentProcessor must keep it where it keeps image URLs.
const (
// ScopeGlobal is the whole tenant, managed by holders of Perms.CommentBan.
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.
Variables ¶
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") // ErrNoClassifier: a free-text poll needs an AnswerClassifier. -> 501 ErrNoClassifier = errors.New("content: free-text polls need an AnswerClassifier") )
Sentinel errors an access.ContentResolver may return to gate a target; mapped to HTTP status without leaking which one beyond the code.
var ErrCommentBanned = errors.New("content: banned from commenting")
ErrCommentBanned refuses a comment write by a banned user. -> 403 comment_banned
var ErrRateLimited = errors.New("content: rate limited")
ErrRateLimited is a refusal a later attempt passes. -> 429 rate_limited
var ErrSubjectErased = fmt.Errorf("content: subject erased")
ErrSubjectErased is returned when an erased account tries to write again.
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".
Functions ¶
func ImageRef ¶ added in v0.70.0
ImageRef is the body reference to an inline image name ("i-{uuid}").
func OwnerScope ¶ added in v0.63.0
OwnerScope is the scope of content whose Resolution.Owner is ownerID, managed by that owner.
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 ActionPostReaction Action = "post_reaction" // like, dislike, neutral on a post ActionReaction Action = "reaction" // like, dislike, neutral on host content ActionFavorite Action = "favorite" // favorite and unfavorite ActionPollVote Action = "poll_vote" // multiple-choice votes and free-text answers )
type ActorReaction ¶
type ActorReaction struct {
contentref.ContentRef
Value int16 `json:"value"` // -1 or 1 (neutral rows are excluded)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
ActorReaction is one row of an actor's reaction history for one kind.
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 AuthorReactions ¶
AuthorReactions is one author's received like/dislike totals across their published comments.
type Authorizer ¶
type Authorizer interface {
Can(ctx context.Context, actor access.Actor, perm string) (bool, error)
}
Authorizer answers whether an actor holds an opaque host permission. Callers are fail-closed: an error is never "allowed".
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.
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 BasicModerator ¶
type BasicModerator struct {
AllowLinks bool // default false: reject bodies containing URLs
DupWindow time.Duration // 0 = 30s; negative disables
CensorWords []string // nil = built-in set; empty = none; whole words, case-insensitive
// contains filtered or unexported fields
}
BasicModerator is the deterministic policy: reject links, near-instant duplicate submissions from one actor and a censor word list. The dup guard is per process; multi-replica hosts wanting global dedup wire their own.
func (*BasicModerator) EraseSubjects ¶
func (*BasicModerator) Screen ¶
func (m *BasicModerator) Screen(_ context.Context, in ModerationInput) (Verdict, error)
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.
type ClassificationPage ¶
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"`
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"`
}
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"`
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
// (Perms.CommentModerate).
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 ContentCanonicalizer ¶
type ContentCanonicalizer interface {
Canonical(ref contentref.ContentRef) (contentref.ContentRef, bool)
}
ContentCanonicalizer maps a resolved reference to the one reference an actor's reaction or favorite is recorded, counted and exported under: per-language routes ("42:en", "42:ja") collapse to one content_id; an explicit content_version_id stays a distinct key; a language suffix never implies a version. ok=false keeps the target out of the preference boundary (its rows keep the resolver's reference and nothing is exported). Comment threads never pass through it. Nil disables export: standalone hosts need no analytics sink.
type ContentCanonicalizerFunc ¶
type ContentCanonicalizerFunc func(ref contentref.ContentRef) (contentref.ContentRef, bool)
ContentCanonicalizerFunc adapts a function to the ContentCanonicalizer port.
func (ContentCanonicalizerFunc) Canonical ¶
func (f ContentCanonicalizerFunc) Canonical(ref contentref.ContentRef) (contentref.ContentRef, bool)
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 ¶
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 FavoriteItem ¶
type FavoriteItem struct {
contentref.ContentRef
CreatedAt time.Time `json:"created_at"`
}
FavoriteItem is one row of the caller's wishlist (newest-first on list).
type FavoriteState ¶ added in v0.68.0
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 Group ¶
Group 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 GroupAssignment ¶
GroupAssignment is the group an answer was placed in at store time.
type HeldItem ¶
type HeldItem struct {
Kind string `json:"kind"` // KindComment | KindPost
Revision int64 `json:"revision"` // required when resolving this exact screened text
ID string `json:"id"`
// Ref is the commented content, or the post'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 post awaiting review.
type Identity ¶
Identity reads the authenticated actor from context; the host's middleware populated it upstream.
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 InlineImage ¶ added in v0.68.0
InlineImage is an inline image of a post: Ref goes in the body where its URL would ("contentkit:i-{uuid}"); URL shows it to the editor now, its public file once the post is published, else its editor view once rendered (null until then; asking renders it).
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
PostReaction []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 gives editors an unpublished post's image previews (POST
// /posts/{id}/images); contentkit.NewRuntime fills it from its Reader.
Reader MediaReader
PostKind string // media kind of post folders; default "post"
PollKind string // media kind of poll folders; default "poll"
}
Media connects post and poll images to ContentKit media. The browser uploads an image to a Named upload path of the post's or poll's item (the host's media registry declares it and its public preset), and hands its name ("i-{uuid}") to ContentKit: covers and poll images store the name, a post body references it (ImageRef). Reads resolve names to the images' current public files, one MediaImages lookup per response. Post visibility changes reconcile public files; 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)
}
MediaImages answers image queries from the current publications in one read; *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 work for a
// comment, the post's own reference for a post.
Ref contentref.ContentRef
Kind string // KindComment | KindPost
ItemID string // the existing item on an edit; empty on create
Title string // posts only
Text string
}
ModerationInput is one comment or post body about to publish. The moderator sees sanitized text and opaque ids only.
type Nullable ¶ added in v0.68.0
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]) MarshalJSON ¶ added in v0.68.0
func (*Nullable[T]) UnmarshalJSON ¶ added in v0.68.0
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 the host-selected schema holding all ContentKit tables.
Schema string
// Tenant scopes every row, index, cursor and result. Required.
Tenant string
// Mandatory ports.
Identity Identity
Authz Authorizer
Resolver access.ContentResolver
// Canonicalizer enables the preference boundary (preferences.go): the
// reaction/favorite row, the counts rollup and the export all use the
// reference it returns. nil = no preference export.
Canonicalizer ContentCanonicalizer
// 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).
Users UserEnricher // default: no enrichment (ids only)
Processor ContentProcessor // comments and post excerpts; default: strip tags
PostBodyProcessor ContentProcessor // post bodies; default: Processor
// Moderator screens comment/post writes; 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 post and 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
// Perms are the opaque host permission strings gating privileged writes.
Perms Perms
// 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
// ContentKinds are the commentable/reactable/favoritable kinds the host
// registers (e.g. "gallery", "video", "post"). Unregistered kinds are 404.
ContentKinds []string
// 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, Identity, Authz and Resolver are mandatory; the rest fall back to documented defaults.
type Perms ¶
type Perms struct {
PostWrite string // create/update/delete posts
PollWrite string // create/update/delete polls + options
CommentModerate string // moderator delete/restore of another actor's comment
ModerationReview string // list and resolve held comments and posts
CommentBan string // ban users from commenting anywhere in the tenant (global scope)
Taxonomy string // the taxonomy admin API contentkit.Runtime.Handler serves at /taxonomy
}
Perms carries the opaque host permission strings checked through Authorizer.Can before privileged writes. An unset gate fails closed.
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 []Group `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
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
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 Post ¶ added in v0.68.0
type Post struct {
ID string `json:"id"`
AuthorID string `json:"author_id"`
Title string `json:"title"`
Slug *string `json:"slug,omitempty"`
// Body shows each image reference (ImageRef) whose image has a current
// public file as that file's URL; the rest stay references.
Body string `json:"body"`
Excerpt *string `json:"excerpt,omitempty"`
// Cover is the cover's image name; CoverURL its current public URL,
// absent until it has one.
Cover *string `json:"cover,omitempty"`
CoverURL *string `json:"cover_url,omitempty"`
// Images maps each resolved image name of Body and Cover to its URL: an
// editor turns those URLs back into references (ImageRef) to save.
Images map[string]string `json:"images,omitempty"`
Language string `json:"language"`
IsDraft bool `json:"is_draft"`
LiveAt *time.Time `json:"live_at,omitempty"`
TotalLikes int `json:"total_likes"`
TotalDislikes int `json:"total_dislikes"`
CommentCount int `json:"comment_count"`
// Moderation is "held" or "rejected" (with the reason) on an unpublished
// post; absent when approved.
Moderation string `json:"moderation,omitempty"`
ModerationReason string `json:"moderation_reason,omitempty"`
// Code and URLSlug build the post's page URL: /{route}/{code}/{url_slug}
// (contenturl).
Code string `json:"code"`
URLSlug string `json:"url_slug"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// DeletedAt is set on a deleted post in the staff list (deleted=true).
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
Post is a post as the API answers it: get, list, create, update and react.
type PostCover ¶ added in v0.68.0
type PostCover struct {
CoverURL *string `json:"cover_url"`
}
PostCover is the cover's public URL after a cover change; null when cleared or while it has no public file (an unpublished post's).
type PostInput ¶ added in v0.68.0
type PostInput struct {
Title *string `json:"title"`
Body *string `json:"body"`
Excerpt *string `json:"excerpt"`
Language *string `json:"language"`
Slug *string `json:"slug"`
IsDraft *bool `json:"is_draft"`
LiveAt *time.Time `json:"live_at"`
}
PostInput is the create and update body. All-pointer so PATCH is partial (nil = leave unchanged); create requires title+body. The cover is set with PUT /posts/{id}/cover.
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
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"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
AvatarSrcSet string `json:"avatar_srcset,omitempty"`
}
PublicUser is display enrichment for an author/actor id.
type RateLimitError ¶ added in v0.63.0
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 {
// 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 ¶
New constructs a Runtime over a schema the ContentKit baseline was applied to and wires the module services.
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 post and poll items: the actor holds Perms.PostWrite or Perms.PollWrite and the post or poll exists in this tenant. Every other target is refused; hosts route their own kinds elsewhere.
func (*Runtime) CheckModerator ¶ added in v0.58.0
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: the profile stat behind "likes my comments got". Like LatestCommentsTotal it takes no actor — held and rejected comments are author-only and never counted, so every reader sees the same totals, and filtering by per-reference visibility would mean resolving every distinct reference the author ever commented on. 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. Preference counts use the canonical work; comment counts keep the original localized thread.
func (*Runtime) EraseSubjects ¶
EraseSubjects fences future authenticated content writes, removes reactions, favorites, poll votes/answers and comment bans of or by them atomically, and removes unpublished held/rejected/draft/scheduled payloads and moderation provenance. A previously published item retains its last approved payload without publishing it again; its unpublished replacement is erased. Never-published items 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) Guard ¶ added in v0.68.0
Guard serves next only to an actor holding the host permission perm, and answers everyone else 403 forbidden, as the content staff routes do.
func (*Runtime) Handler ¶
Handler returns the content routes. contentkit.Runtime.Handler serves them at the root of its mount; a host mounting them alone puts them under a prefix after its own auth middleware populated the identity. Each request runs in an access.WithMemo context, so a Gate resolver reads billing at most once per request.
func (*Runtime) IsFavorited ¶
func (rt *Runtime) IsFavorited(ctx context.Context, userID string, refs []contentref.ContentRef) (map[contentref.ContentKey]bool, error)
IsFavorited batch-checks bookmarks for a user (every requested key is present in the map, absent bookmarks => false).
func (*Runtime) KeywordDocuments ¶
func (rt *Runtime) KeywordDocuments(ctx context.Context, tenant, kind, language string, refs []contentref.ContentRef) ([]search.KeywordDocument, error)
KeywordDocuments returns the current keyword document of every published post in refs for language (the worker's BuildKeywordDocuments shape for kind "post"). A draft, unpublished, deleted or other-language post yields no document, which deletes its stale index entry; so does a held or rejected one.
func (*Runtime) LatestComments ¶
func (rt *Runtime) LatestComments(ctx context.Context, actor access.Actor, limit, offset int) ([]FeedItem, error)
LatestComments is the host-facing feed API: newest comments across all content the actor may see, with canonical references for host hydration.
func (*Runtime) LatestCommentsTotal ¶
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 — filtering it exactly would mean resolving every distinct reference in the whole table.
func (*Runtime) ListContent ¶
func (rt *Runtime) ListContent(ctx context.Context, tenant, kind, language, cursor string, limit int) ([]contentref.ContentRef, string, bool, error)
ListContent enumerates the tenant's published posts of language in id order (the worker's ListContentPage shape for kind "post"); cursor is the last id.
func (*Runtime) ListFavorites ¶
func (rt *Runtime) ListFavorites(ctx context.Context, userID string, limit, offset int) ([]FavoriteItem, error)
ListFavorites returns userID's bookmarks newest-first: the host-facing Go API a hydrated list route reads its references from. limit <= 0 means all.
func (*Runtime) ListHeld ¶
ListHeld pages this tenant's held comments or posts 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() access.ContentResolver
MediaResolver is the access.ContentResolver of the post and poll media kinds: route the host registry's Hooks.Resolver to it for Media.PostKind and PollKind, as CanUpload is routed. A post's folder shows like the post: a published one to everyone, a draft, scheduled, held or rejected one only to its author and PostWrite holders, its editors (an editor read gives them the images before anyone else sees them), a deleted one to no one. A poll's folder shows while the poll exists, edited by PollWrite holders. Other refs are omitted (denied).
func (*Runtime) MyReactions ¶
func (rt *Runtime) MyReactions(ctx context.Context, actor access.Actor, refs []contentref.ContentRef) (map[contentref.ContentKey]int16, error)
MyReactions batch-reads the actor's own reaction (-1/0/1) for many refs: the hydration read for list/detail responses, keyed by the caller's references and read under their canonical preference references (the identity the write path stores under). Only nonzero reactions appear.
func (*Runtime) PostVisibility ¶ added in v0.67.0
func (rt *Runtime) PostVisibility(ctx context.Context, id string) (contenturl.Visibility, error)
loadByID returns a single non-deleted post (draft or published) of the tenant. PostVisibility is what a post's public URL shows, for a host's contenturl.RouterOptions.Visibility on the post kind: Visible when published, Gone when deleted after it was public, Hidden otherwise (draft, scheduled, held, rejected, deleted before it was ever public, unknown).
func (*Runtime) ReactionsByActor ¶
func (rt *Runtime) ReactionsByActor(ctx context.Context, actor access.Actor, kind string, limit, offset int) ([]ActorReaction, error)
ReactionsByActor lists the actor's nonzero reactions of one kind, newest-first (e.g. a "my tag preferences" page). limit <= 0 means all.
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 ¶
Resolve writes a reviewer's final decision on a held item. Approve publishes it (counts and, for posts, the keyword index follow); reject keeps it author-visible with the reason. A missing, foreign-tenant or not-held item is ErrNotFound.
func (*Runtime) ResolvePosts ¶ added in v0.70.0
ResolvePosts resolves the posts' images with one projection lookup: each reference in Body (ImageRef) to an image with a current public file becomes its URL, CoverURL is Cover's, and Images maps every resolved name to its URL. A reference without a current file (an unpublished post, an image still rendering) stays as it is, CoverURL nil. Body is the stored body; the post routes answer posts resolved. Hosts reading content_posts themselves resolve with it before rendering.
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 unless preference export is disabled.
func (*Runtime) SeedPreferenceRevisionFloor ¶
SeedPreferenceRevisionFloor raises the schema's revision sequence so every future revision exceeds floor, and returns the resulting floor. It only ever advances and fails closed outside the safe range. Run it after restoring PostgreSQL against a retained signal plane, before writers resume.
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. Export disabled (nil Canonicalizer) sends nothing; otherwise send is required.
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 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 UserEnricher ¶
type UserEnricher interface {
UsersByIDs(ctx context.Context, ids []string) (map[string]PublicUser, error)
}
UserEnricher batch-loads display data for author/actor ids.
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.