@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +929 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +150 -0
- package/package.json +52 -0
package/dist/errors.d.ts
ADDED
|
@@ -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
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|