@voltro/plugin-moderation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ import { Schema } from 'effect';
2
+
3
+ /** Raised when a `block`-action rule flags the content — the write never
4
+ * commits. Merged into every procedure's wire-error union (typed on the client). */
5
+ export declare class ContentRejected extends ContentRejected_base {
6
+ }
7
+
8
+ declare const ContentRejected_base: Schema.TaggedErrorClass<ContentRejected, "ContentRejected", {
9
+ readonly _tag: Schema.tag<"ContentRejected">;
10
+ } & {
11
+ /** The rpc tag that was moderated. */
12
+ tag: typeof Schema.String;
13
+ /** Matched categories / keywords. */
14
+ categories: Schema.Array$<typeof Schema.String>;
15
+ reason: typeof Schema.String;
16
+ }>;
17
+
18
+ export { }
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ import { Schema as e } from "effect";
2
+ //#region src/errors.ts
3
+ var t = class extends e.TaggedError()("ContentRejected", {
4
+ tag: e.String,
5
+ categories: e.Array(e.String),
6
+ reason: e.String
7
+ }) {};
8
+ //#endregion
9
+ export { t as ContentRejected };
@@ -0,0 +1,117 @@
1
+ import { Context } from 'effect';
2
+ import { Effect } from 'effect';
3
+ import { generateObject } from '@voltro/ai';
4
+ import { Schema } from 'effect';
5
+ import { VoltroPlugin } from '@voltro/protocol';
6
+
7
+ /** AI moderation via @voltro/ai (lazy, OPTIONAL dependency). Uses a structured
8
+ * classification. `threshold` (0..1) decides flagged from the model's max
9
+ * category score.
10
+ *
11
+ * Two distinct failure modes — the distinction matters because a silent
12
+ * false-pass is the worst error class for a security control:
13
+ * - `@voltro/ai` NOT INSTALLED (import rejects) → a configuration error.
14
+ * Warn ONCE (so "all content silently passes" is never silent), fail open.
15
+ * - per-call model error / outage → fail open WITHOUT warning (a transient
16
+ * outage must not block every write, and would spam the log). */
17
+ export declare const aiProvider: (opts?: AiProviderOptions) => ModerationProvider;
18
+
19
+ declare interface AiProviderOptions {
20
+ readonly threshold?: number;
21
+ readonly categories?: ReadonlyArray<string>;
22
+ /** Importer seam for the optional `@voltro/ai` dependency — defaults to the
23
+ * real lazy import. Tests inject a stub to drive the verdict logic AND the
24
+ * missing-dependency branch deterministically (mirrors @voltro/ai's own
25
+ * injectable gatewayProvider). */
26
+ readonly load?: () => Promise<{
27
+ readonly generateObject: typeof generateObject;
28
+ }>;
29
+ }
30
+
31
+ /** Raised when a `block`-action rule flags the content — the write never
32
+ * commits. Merged into every procedure's wire-error union (typed on the client). */
33
+ export declare class ContentRejected extends ContentRejected_base {
34
+ }
35
+
36
+ declare const ContentRejected_base: Schema.TaggedErrorClass<ContentRejected, "ContentRejected", {
37
+ readonly _tag: Schema.tag<"ContentRejected">;
38
+ } & {
39
+ /** The rpc tag that was moderated. */
40
+ tag: typeof Schema.String;
41
+ /** Matched categories / keywords. */
42
+ categories: Schema.Array$<typeof Schema.String>;
43
+ reason: typeof Schema.String;
44
+ }>;
45
+
46
+ /** A flagged-content item awaiting review (the dashboard queue). */
47
+ export declare interface FlaggedItem {
48
+ readonly id: string;
49
+ readonly tag: string;
50
+ readonly categories: ReadonlyArray<string>;
51
+ readonly reason: string;
52
+ readonly subjectId: string | null;
53
+ readonly at: string;
54
+ status: 'pending' | 'confirmed' | 'dismissed';
55
+ }
56
+
57
+ /** Zero-dep denylist matcher. Case-insensitive whole-word match. */
58
+ export declare const keywordProvider: (deny: ReadonlyArray<string>) => ModerationProvider;
59
+
60
+ /** Moderate arbitrary text in a handler (e.g. to redact before writing) — the
61
+ * fine-grained escape the rpc interceptor can't do (it can't rewrite input).
62
+ * Reads the configured provider off `ModerationService`; never throws. */
63
+ export declare const moderate: (text: string) => Effect.Effect<ModerationVerdict, never, ModerationService>;
64
+
65
+ export declare const moderationPlugin: (options: ModerationPluginOptions) => VoltroPlugin;
66
+
67
+ export declare interface ModerationPluginOptions {
68
+ readonly provider: ModerationProvider;
69
+ readonly rules: ReadonlyArray<ModerationRule>;
70
+ /** Called when a `flag`-action rule (or any flagged content) is seen. */
71
+ readonly onFlag?: (info: {
72
+ tag: string;
73
+ verdict: ModerationVerdict;
74
+ }) => void;
75
+ readonly name?: string;
76
+ }
77
+
78
+ export declare type ModerationProvider = (text: string) => Effect.Effect<ModerationVerdict, never, never>;
79
+
80
+ export declare interface ModerationRule {
81
+ /** rpc tag (exact) or RegExp on the tag. */
82
+ readonly match: string | RegExp;
83
+ /** Input field keys whose string values are concatenated + moderated. */
84
+ readonly fields: ReadonlyArray<string>;
85
+ /** `block` (default) fails the call; `flag` allows + records. */
86
+ readonly action?: 'block' | 'flag';
87
+ }
88
+
89
+ /** `Context.Tag` for the moderation service — `yield* ModerationService` in any
90
+ * handler once `moderationPlugin(...)` is wired. The handler depends on the
91
+ * Tag; the plugin owns the provider-backed implementation (no module-level
92
+ * global). Mirrors `@voltro/plugin-mail`'s `MailService`. */
93
+ export declare class ModerationService extends ModerationService_base {
94
+ }
95
+
96
+ declare const ModerationService_base: Context.TagClass<ModerationService, "@voltro/plugin-moderation/ModerationService", ModerationServiceShape>;
97
+
98
+ /** The moderation runtime surface a handler reads to classify arbitrary text.
99
+ * Backed by the configured provider; contributed once per app by
100
+ * `moderationPlugin(...)` via `services`, mirroring `MailService` /
101
+ * `StorageService`. */
102
+ export declare interface ModerationServiceShape {
103
+ /** Classify text against the configured provider. Never fails. */
104
+ readonly moderate: (text: string) => Effect.Effect<ModerationVerdict, never, never>;
105
+ }
106
+
107
+ export declare interface ModerationVerdict {
108
+ /** True when the content violates policy. */
109
+ readonly flagged: boolean;
110
+ /** Matched categories (e.g. 'hate', 'sexual', 'self-harm', or a keyword). */
111
+ readonly categories?: ReadonlyArray<string>;
112
+ /** Highest category score 0..1 (AI providers). */
113
+ readonly score?: number;
114
+ readonly reason?: string;
115
+ }
116
+
117
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ import { ContentRejected as e } from "./errors.js";
2
+ import { Context as t, Effect as n, Layer as r } from "effect";
3
+ import { definePlugin as i } from "@voltro/protocol";
4
+ import { createLogger as a } from "@voltro/logger";
5
+ //#region src/providers.ts
6
+ var o = a({ scope: "@voltro/plugin-moderation" }), s = { flagged: !1 }, c = (e) => {
7
+ let t = e.map((e) => e.toLowerCase());
8
+ return (e) => n.sync(() => {
9
+ let n = e.toLowerCase(), r = t.filter((e) => RegExp(`\\b${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(n));
10
+ return r.length > 0 ? {
11
+ flagged: !0,
12
+ categories: r,
13
+ reason: `matched denied term(s): ${r.join(", ")}`
14
+ } : s;
15
+ });
16
+ }, l = (e = {}) => {
17
+ let t = e.threshold ?? .7, r = e.categories ?? [
18
+ "hate",
19
+ "harassment",
20
+ "sexual",
21
+ "violence",
22
+ "self-harm",
23
+ "illicit"
24
+ ], i = e.load ?? (() => import("@voltro/ai")), a = !1;
25
+ return (e) => n.gen(function* () {
26
+ let c = yield* n.tryPromise(i).pipe(n.catchAll(() => n.succeed(null)));
27
+ return c === null ? (a || (a = !0, o.warn("aiProvider configured but @voltro/ai could not be loaded — content is NOT being moderated; install @voltro/ai")), s) : yield* n.tryPromise(async () => {
28
+ let { Schema: i } = await import("effect"), a = i.Struct({
29
+ flagged: i.Boolean,
30
+ topCategory: i.NullOr(i.String),
31
+ score: i.Number
32
+ }), { object: o } = await n.runPromise(c.generateObject({
33
+ schema: a,
34
+ system: `You are a content moderator. Categories: ${r.join(", ")}. Return whether the content violates policy, the worst category, and a 0..1 severity score.`,
35
+ prompt: e
36
+ })), s = o.flagged || o.score >= t;
37
+ return {
38
+ flagged: s,
39
+ score: o.score,
40
+ ...o.topCategory ? { categories: [o.topCategory] } : {},
41
+ ...s ? { reason: `ai moderation: ${o.topCategory ?? "policy"} (${o.score.toFixed(2)})` } : {}
42
+ };
43
+ }).pipe(n.catchAll(() => n.succeed(s)));
44
+ });
45
+ }, u = class extends t.Tag("@voltro/plugin-moderation/ModerationService")() {}, d = (e) => n.flatMap(u, (t) => t.moderate(e)), f = (e, t) => {
46
+ if (typeof e != "object" || !e) return "";
47
+ let n = e;
48
+ return t.map((e) => n[e]).filter((e) => typeof e == "string").join("\n");
49
+ }, p = (t) => {
50
+ let a = t.name ? `@voltro/plugin-moderation#${t.name}` : "@voltro/plugin-moderation", o = (e) => t.rules.filter((t) => typeof t.match == "string" ? t.match === e : t.match.test(e)), s = t.onFlag, c = () => void 0, l = [], d = 0, p = 0, m = 0, h = (e, t, n) => {
51
+ l.unshift({
52
+ id: `mod_${Date.now().toString(36)}_${d++}`,
53
+ tag: e,
54
+ categories: n.categories ?? [],
55
+ reason: n.reason ?? "content policy violation",
56
+ subjectId: t,
57
+ at: (/* @__PURE__ */ new Date()).toISOString(),
58
+ status: "pending"
59
+ }), l.length > 200 && (l.length = 200);
60
+ }, g = (r, i) => {
61
+ let a = o(i.tag);
62
+ if (a.length === 0) return r;
63
+ let l = i.subject?.id ?? null;
64
+ return n.gen(function* () {
65
+ for (let r of a) {
66
+ let a = f(i.input, r.fields);
67
+ if (a.length === 0) continue;
68
+ let o = yield* t.provider(a);
69
+ if (!o.flagged) continue;
70
+ let u = o.categories ?? [], d = o.reason ?? "content policy violation";
71
+ if ((r.action ?? "block") === "block") return p++, h(i.tag, l, o), yield* n.fail(new e({
72
+ tag: i.tag,
73
+ categories: u,
74
+ reason: d
75
+ }));
76
+ m++, h(i.tag, l, o), c(i.tag, o), s?.({
77
+ tag: i.tag,
78
+ verdict: o
79
+ });
80
+ }
81
+ return yield* r;
82
+ });
83
+ }, _ = (e) => ({
84
+ kind: "json",
85
+ data: e
86
+ }), v = (e, t) => ({
87
+ kind: "json",
88
+ status: e,
89
+ data: { error: t }
90
+ }), y = [{
91
+ method: "GET",
92
+ path: "/flagged",
93
+ description: "The content-moderation review queue (newest first).",
94
+ handler: () => n.succeed(_({
95
+ items: l.slice(0, 100),
96
+ stats: {
97
+ blocked: p,
98
+ flagged: m,
99
+ pending: l.filter((e) => e.status === "pending").length
100
+ }
101
+ }))
102
+ }, {
103
+ method: "POST",
104
+ path: "/resolve",
105
+ description: "Resolve a flagged item — confirm (real violation) or dismiss (false positive).",
106
+ handler: (e) => n.sync(() => {
107
+ let t;
108
+ try {
109
+ t = JSON.parse(e.body || "{}");
110
+ } catch {
111
+ return v(400, "invalid JSON body");
112
+ }
113
+ if (!t.id || t.action !== "confirm" && t.action !== "dismiss") return v(400, "id + action (confirm|dismiss) required");
114
+ let n = l.find((e) => e.id === t.id);
115
+ return n ? (n.status = t.action === "confirm" ? "confirmed" : "dismissed", _({
116
+ ok: !0,
117
+ item: n
118
+ })) : v(404, `unknown item "${t.id}"`);
119
+ })
120
+ }], b = [
121
+ "rpc:intercept:mutation",
122
+ "rpc:intercept:action",
123
+ "inspect:read"
124
+ ], x = r.succeed(u, { moderate: (e) => t.provider(e) });
125
+ return i({
126
+ name: a,
127
+ description: "Content moderation — block or flag user-content writes via an AI / keyword / custom check.",
128
+ permissions: b,
129
+ errorSchemas: [{
130
+ schema: e,
131
+ import: {
132
+ module: "@voltro/plugin-moderation/errors",
133
+ name: "ContentRejected"
134
+ }
135
+ }],
136
+ interceptMutation: g,
137
+ interceptAction: g,
138
+ inspectEndpoints: y,
139
+ services: x,
140
+ onActivate: (e) => n.sync(() => {
141
+ c = (t, n) => e.logger.warn("content flagged", {
142
+ tag: t,
143
+ categories: n.categories,
144
+ score: n.score
145
+ }), s = t.onFlag, e.logger.info("moderation active", { rules: t.rules.length });
146
+ })
147
+ });
148
+ };
149
+ //#endregion
150
+ export { e as ContentRejected, u as ModerationService, l as aiProvider, c as keywordProvider, d as moderate, p as moderationPlugin };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@voltro/plugin-moderation",
3
+ "version": "0.1.0",
4
+ "description": "Content moderation — run an AI / keyword / custom check on user-content fields and block or flag a write before it commits. Declarative per-rpc rules + an in-handler moderate() helper for redact/inline control.",
5
+ "keywords": [
6
+ "voltro",
7
+ "typescript",
8
+ "framework"
9
+ ],
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "homepage": "https://voltro.dev",
12
+ "bugs": {
13
+ "email": "support@voltro.dev"
14
+ },
15
+ "author": {
16
+ "name": "Voltro UG",
17
+ "url": "https://voltro.dev"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./errors": {
27
+ "types": "./dist/errors.d.ts",
28
+ "import": "./dist/errors.js",
29
+ "default": "./dist/errors.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "sideEffects": false,
36
+ "engines": {
37
+ "node": ">=24.0.0"
38
+ },
39
+ "dependencies": {
40
+ "@voltro/logger": "0.1.0",
41
+ "@voltro/protocol": "0.1.0"
42
+ },
43
+ "optionalDependencies": {
44
+ "@voltro/ai": "0.1.0"
45
+ },
46
+ "peerDependencies": {
47
+ "effect": "^3.21.4"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }