API · Moderation

Pre-commit content moderation with @voltro/plugin-moderation — checks named input fields BEFORE the handler; a block rule rejects banned content with typed ContentRejected (write never commits), a flag rule writes it but queues it for review. keywordProvider or aiProvider. Memory store, zero infra.

Stop bad content before it's written. @voltro/plugin-moderation checks named input fields on the rpc interceptors, before the handler: a block rule rejects the call with typed ContentRejected (the write never commits); a flag rule lets the write through but queues it for review. The provider is pluggable — keywordProvider is a zero-dep deny-list, aiProvider() an optional LLM classifier. Config-only, zero infra. Template id: api-moderation.

Scaffold

voltro create-project acme --api=api-moderation

Rules as config

// app.config.ts
import { moderationPlugin, keywordProvider } from '@voltro/plugin-moderation'

moderationPlugin({
  provider: keywordProvider(['spam', 'scam', 'phishing', 'malware']),
  rules: [
    { match: 'posts.create',    fields: ['title', 'body'], action: 'block' },   // reject
    { match: 'comments.create', fields: ['body'],          action: 'flag'  },   // write + queue
  ],
})

There's no moderation code in the handlers — posts.create and comments.create are plain inserts; the interceptor enforces the rules.

Block vs flag

  • block — banned content fails typed ContentRejected ({ tag, categories, reason }) before the write. Declare error: ContentRejected (from @voltro/plugin-moderation/errors) on the procedure.
  • flag — the row IS written, but flagged; the dashboard's Moderation panel surfaces the review queue. For rewriting rather than rejecting, the in-handler moderate(text) helper returns a verdict you act on (the interceptor can't rewrite input).

Try it

# banned word in a post → blocked, nothing written:
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"posts.create","input":{"title":"hi","body":"buy cheap spam now"}}'
# → { ok:false, error:{ _tag:"ContentRejected", reason:"matched denied term(s): spam" } }

# clean post → written; banned COMMENT → written but flagged (flag doesn't block):
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"comments.create","input":{"body":"this is spam"}}'
# → { ok:true, … }   (queued for review)

Swap in AI

Replace keywordProvider([...]) with aiProvider() — the rules don't change. aiProvider fails OPEN (a classifier outage lets content through rather than blocking everything).

Anti-patterns

  • Putting the keyword list in the client. Moderation runs server-side on the interceptor — a client check is bypassable. Keep the provider + rules on the api.
  • Using block where you meant flag. block is a hard reject (nothing written); flag keeps the content for human review. Pick per surface — comments often flag, public posts often block.