Skip to content

Commit 34b654f

Browse files
authored
Add ClawHub publisher abuse digest endpoint (#17)
1 parent 268a0d1 commit 34b654f

4 files changed

Lines changed: 900 additions & 0 deletions

File tree

src/clawhubPublisherAbuse/api.ts

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
import {
2+
Container,
3+
Separator,
4+
TextDisplay,
5+
type Client,
6+
type MessagePayloadObject
7+
} from "@buape/carbon"
8+
import { formSettings } from "../../forms.config.js"
9+
import { getRuntimeEnv } from "../runtime/env.js"
10+
11+
type PublisherAbuseSignal = {
12+
signalId: string
13+
signalType: string
14+
severity: string
15+
publisher: string
16+
skillSlug: string
17+
skillDisplayName: string | null
18+
seenCount: number
19+
firstSeenAt: number | null
20+
lastSeenAt: number | null
21+
recent7Downloads: number | null
22+
recent7Installs: number | null
23+
recent7InstallDownloadRatio: number | null
24+
recent30Downloads: number | null
25+
recent30Installs: number | null
26+
recent30InstallDownloadRatio: number | null
27+
allTimeDownloads: number | null
28+
allTimeInstalls: number | null
29+
allTimeInstallDownloadRatio: number | null
30+
skillUrl: string | null
31+
publisherUrl: string | null
32+
}
33+
34+
type PublisherAbuseDigest = {
35+
kind: "publisher_abuse_signals_changed"
36+
changedCount: number
37+
hasMore: boolean
38+
dashboardUrl: string
39+
topSignals: PublisherAbuseSignal[]
40+
}
41+
42+
type PublisherAbuseDiscordMessage = {
43+
components: Container[]
44+
allowedMentions: NonNullable<MessagePayloadObject["allowedMentions"]>
45+
}
46+
47+
type SendableChannel = {
48+
send: (message: PublisherAbuseDiscordMessage) => Promise<unknown>
49+
}
50+
51+
type PublisherAbuseDigestApiDependencies = {
52+
token: string
53+
trustedOrigins?: string[]
54+
fetchChannel: (channelId: string) => Promise<unknown>
55+
}
56+
57+
const apiPath = "/api/clawhub-publisher-abuse/signals/digest"
58+
const defaultClawHubSiteUrl = "https://clawhub.ai"
59+
60+
const jsonResponse = (value: unknown, status = 200) =>
61+
new Response(JSON.stringify(value), {
62+
status,
63+
headers: { "content-type": "application/json" }
64+
})
65+
66+
const bearerToken = (request: Request) =>
67+
request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1] ?? ""
68+
69+
const readRecord = (value: unknown) =>
70+
value && typeof value === "object" && !Array.isArray(value)
71+
? value as Record<string, unknown>
72+
: null
73+
74+
const requiredString = (value: unknown) =>
75+
typeof value === "string" && value.trim() ? value.trim() : null
76+
77+
const optionalString = (value: unknown) =>
78+
typeof value === "string" && value.trim() ? value.trim() : null
79+
80+
const nonNegativeInteger = (value: unknown) =>
81+
typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null
82+
83+
const finiteNumber = (value: unknown) =>
84+
typeof value === "number" && Number.isFinite(value) ? value : null
85+
86+
const nonNegativeNumber = (value: unknown) => {
87+
const number = finiteNumber(value)
88+
return number !== null && number >= 0 ? number : null
89+
}
90+
91+
const optionalNonNegativeInteger = (value: unknown) =>
92+
value === undefined || value === null ? null : nonNegativeInteger(value)
93+
94+
const optionalNonNegativeNumber = (value: unknown) =>
95+
value === undefined || value === null ? null : nonNegativeNumber(value)
96+
97+
const urlOrigin = (value: string) => {
98+
try {
99+
const url = new URL(value)
100+
return ["http:", "https:"].includes(url.protocol) ? url.origin : null
101+
} catch {
102+
return null
103+
}
104+
}
105+
106+
export const publisherAbuseDigestTrustedOrigins = (
107+
env: Pick<Env, "CLAWHUB_SITE_URL">
108+
) => [urlOrigin(env.CLAWHUB_SITE_URL?.trim() || defaultClawHubSiteUrl) ?? defaultClawHubSiteUrl]
109+
110+
const trustedOriginSet = (origins: string[]) =>
111+
new Set(origins.map((origin) => urlOrigin(origin.trim()) ?? origin.trim()).filter(Boolean))
112+
113+
const validUrl = (value: unknown, trustedOrigins: ReadonlySet<string>) => {
114+
const url = requiredString(value)
115+
if (!url) {
116+
return null
117+
}
118+
try {
119+
const parsed = new URL(url)
120+
return ["http:", "https:"].includes(parsed.protocol) && trustedOrigins.has(parsed.origin)
121+
? parsed.toString()
122+
: null
123+
} catch {
124+
return null
125+
}
126+
}
127+
128+
const optionalValidUrl = (value: unknown, trustedOrigins: ReadonlySet<string>) =>
129+
value === undefined || value === null || value === "" ? null : validUrl(value, trustedOrigins)
130+
131+
const invalidOptionalUrl = (rawValue: unknown, parsedValue: string | null) =>
132+
rawValue !== undefined && rawValue !== null && rawValue !== "" && parsedValue === null
133+
134+
const optionalIntegerFields = [
135+
"firstSeenAt",
136+
"lastSeenAt",
137+
"recent7Downloads",
138+
"recent7Installs",
139+
"recent30Downloads",
140+
"recent30Installs",
141+
"allTimeDownloads",
142+
"allTimeInstalls"
143+
]
144+
145+
const optionalRatioFields = [
146+
"recent7InstallDownloadRatio",
147+
"recent30InstallDownloadRatio",
148+
"allTimeInstallDownloadRatio"
149+
]
150+
151+
const hasInvalidOptionalInteger = (record: Record<string, unknown>) =>
152+
optionalIntegerFields.some((field) =>
153+
record[field] !== undefined &&
154+
record[field] !== null &&
155+
nonNegativeInteger(record[field]) === null
156+
)
157+
158+
const hasInvalidOptionalRatio = (record: Record<string, unknown>) =>
159+
optionalRatioFields.some((field) =>
160+
record[field] !== undefined &&
161+
record[field] !== null &&
162+
nonNegativeNumber(record[field]) === null
163+
)
164+
165+
const parseSignal = (value: unknown, trustedOrigins: ReadonlySet<string>): PublisherAbuseSignal | null => {
166+
const record = readRecord(value)
167+
if (!record) {
168+
return null
169+
}
170+
171+
const signalId = requiredString(record.signalId)
172+
const signalType = requiredString(record.signalType)
173+
const severity = requiredString(record.severity)
174+
const publisher = requiredString(record.publisher)
175+
const skillSlug = requiredString(record.skillSlug)
176+
const seenCount = nonNegativeInteger(record.seenCount)
177+
const skillUrl = optionalValidUrl(record.skillUrl, trustedOrigins)
178+
const publisherUrl = optionalValidUrl(record.publisherUrl, trustedOrigins)
179+
180+
if (
181+
!signalId ||
182+
!signalType ||
183+
!severity ||
184+
!publisher ||
185+
!skillSlug ||
186+
seenCount === null ||
187+
invalidOptionalUrl(record.skillUrl, skillUrl) ||
188+
invalidOptionalUrl(record.publisherUrl, publisherUrl) ||
189+
hasInvalidOptionalInteger(record) ||
190+
hasInvalidOptionalRatio(record)
191+
) {
192+
return null
193+
}
194+
195+
return {
196+
signalId,
197+
signalType,
198+
severity,
199+
publisher,
200+
skillSlug,
201+
skillDisplayName: optionalString(record.skillDisplayName),
202+
seenCount,
203+
firstSeenAt: optionalNonNegativeInteger(record.firstSeenAt),
204+
lastSeenAt: optionalNonNegativeInteger(record.lastSeenAt),
205+
recent7Downloads: optionalNonNegativeInteger(record.recent7Downloads),
206+
recent7Installs: optionalNonNegativeInteger(record.recent7Installs),
207+
recent7InstallDownloadRatio: optionalNonNegativeNumber(record.recent7InstallDownloadRatio),
208+
recent30Downloads: optionalNonNegativeInteger(record.recent30Downloads),
209+
recent30Installs: optionalNonNegativeInteger(record.recent30Installs),
210+
recent30InstallDownloadRatio: optionalNonNegativeNumber(record.recent30InstallDownloadRatio),
211+
allTimeDownloads: optionalNonNegativeInteger(record.allTimeDownloads),
212+
allTimeInstalls: optionalNonNegativeInteger(record.allTimeInstalls),
213+
allTimeInstallDownloadRatio: optionalNonNegativeNumber(record.allTimeInstallDownloadRatio),
214+
skillUrl,
215+
publisherUrl
216+
}
217+
}
218+
219+
const parseDigest = (value: unknown, trustedOrigins: ReadonlySet<string>): PublisherAbuseDigest | null => {
220+
const record = readRecord(value)
221+
if (!record || record.kind !== "publisher_abuse_signals_changed") {
222+
return null
223+
}
224+
225+
const changedCount = nonNegativeInteger(record.changedCount)
226+
const dashboardUrl = validUrl(record.dashboardUrl, trustedOrigins)
227+
const topSignals = Array.isArray(record.topSignals)
228+
? record.topSignals.map((signal) => parseSignal(signal, trustedOrigins))
229+
: []
230+
231+
if (
232+
changedCount === null ||
233+
typeof record.hasMore !== "boolean" ||
234+
!dashboardUrl ||
235+
topSignals.length === 0 ||
236+
topSignals.some((signal) => signal === null)
237+
) {
238+
return null
239+
}
240+
241+
return {
242+
kind: "publisher_abuse_signals_changed",
243+
changedCount,
244+
hasMore: record.hasMore,
245+
dashboardUrl,
246+
topSignals: topSignals.filter((signal): signal is PublisherAbuseSignal => signal !== null)
247+
}
248+
}
249+
250+
const isSendableChannel = (channel: unknown): channel is SendableChannel => {
251+
const record = readRecord(channel)
252+
return typeof record?.send === "function"
253+
}
254+
255+
const plural = (count: number, singular: string, pluralValue = `${singular}s`) =>
256+
count === 1 ? singular : pluralValue
257+
258+
const reviewVerb = (count: number) => count === 1 ? "needs" : "need"
259+
260+
const titleCaseSignalType = (signalType: string) =>
261+
signalType
262+
.split(/[_\s-]+/)
263+
.filter(Boolean)
264+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
265+
.join(" ")
266+
267+
const oneLineText = (value: string) =>
268+
value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim()
269+
270+
const markdownText = (value: string) =>
271+
oneLineText(value).replace(/([\\`*_~|>\[\]()#])/g, "\\$1")
272+
273+
const markdownUrl = (value: string) => {
274+
const safeUrl = value.replace(/[<>]/g, (character) => character === "<" ? "%3C" : "%3E")
275+
return `<${safeUrl}>`
276+
}
277+
278+
const metricLine = (signal: PublisherAbuseSignal) => {
279+
if (
280+
signal.recent7Downloads === null ||
281+
signal.recent7Installs === null ||
282+
signal.recent7InstallDownloadRatio === null
283+
) {
284+
return null
285+
}
286+
287+
return `7d: ${signal.recent7Installs.toLocaleString()} installs / ${signal.recent7Downloads.toLocaleString()} downloads (${(signal.recent7InstallDownloadRatio * 100).toFixed(1)}%)`
288+
}
289+
290+
const signalLinks = (signal: PublisherAbuseSignal) => [
291+
signal.skillUrl ? `[Skill](${markdownUrl(signal.skillUrl)})` : null,
292+
signal.publisherUrl ? `[Publisher](${markdownUrl(signal.publisherUrl)})` : null
293+
].filter((link): link is string => Boolean(link))
294+
295+
const signalText = (signal: PublisherAbuseSignal) => {
296+
const title = markdownText(signal.skillDisplayName ?? signal.skillSlug)
297+
const links = signalLinks(signal)
298+
return [
299+
`**${markdownText(titleCaseSignalType(signal.signalType))}** · ${markdownText(signal.severity.toUpperCase())}`,
300+
`${title} · ${markdownText(signal.publisher)}/${markdownText(signal.skillSlug)}`,
301+
`Seen ${signal.seenCount}x`,
302+
metricLine(signal),
303+
links.length ? links.join(" · ") : null
304+
].filter((line): line is string => Boolean(line)).join("\n")
305+
}
306+
307+
export const publisherAbuseDigestApiToken = (
308+
env: Partial<Pick<Env, "CLAWHUB_BAN_APPEALS_TOKEN" | "CLAWHUB_HERMIT_TOKEN">>
309+
) => env.CLAWHUB_HERMIT_TOKEN?.trim() || env.CLAWHUB_BAN_APPEALS_TOKEN?.trim() || ""
310+
311+
export const buildPublisherAbuseDigestContainer = (digest: PublisherAbuseDigest) =>
312+
new Container(
313+
[
314+
new TextDisplay(`<@&${formSettings.clawhubAppealReviewRoleId}>`),
315+
new TextDisplay("### ClawHub publisher abuse signals changed"),
316+
new TextDisplay(
317+
`${digest.changedCount.toLocaleString()} changed ${plural(digest.changedCount, "signal")} ${reviewVerb(digest.changedCount)} review.\n[Open ClawHub abuse signals](${markdownUrl(digest.dashboardUrl)})`
318+
),
319+
new Separator({ divider: true, spacing: "small" }),
320+
...digest.topSignals.slice(0, 5).map((signal) => new TextDisplay(signalText(signal))),
321+
...(digest.hasMore || digest.topSignals.length > 5
322+
? [new TextDisplay("More signals are available in ClawHub.")]
323+
: [])
324+
],
325+
{ accentColor: "#f2c94c" }
326+
)
327+
328+
export const handlePublisherAbuseDigestApi = async (
329+
request: Request,
330+
dependencies: PublisherAbuseDigestApiDependencies
331+
): Promise<Response | null> => {
332+
const url = new URL(request.url)
333+
if (url.pathname !== apiPath) {
334+
return null
335+
}
336+
if (!dependencies.token || bearerToken(request) !== dependencies.token) {
337+
return jsonResponse({ error: "Unauthorized" }, 401)
338+
}
339+
if (request.method !== "POST") {
340+
return jsonResponse({ error: "Method not allowed" }, 405)
341+
}
342+
343+
let body: unknown
344+
try {
345+
body = await request.json()
346+
} catch {
347+
return jsonResponse({ error: "Invalid JSON" }, 400)
348+
}
349+
350+
const trustedOrigins = trustedOriginSet(dependencies.trustedOrigins ?? [defaultClawHubSiteUrl])
351+
const digest = parseDigest(body, trustedOrigins)
352+
if (!digest) {
353+
return jsonResponse({ error: "Invalid publisher abuse digest payload" }, 400)
354+
}
355+
356+
const channel = await dependencies.fetchChannel(formSettings.clawhubAppealReviewChannelId)
357+
if (!isSendableChannel(channel)) {
358+
throw new Error(`Review channel ${formSettings.clawhubAppealReviewChannelId} is not sendable.`)
359+
}
360+
361+
await channel.send({
362+
components: [buildPublisherAbuseDigestContainer(digest)],
363+
allowedMentions: {
364+
roles: [formSettings.clawhubAppealReviewRoleId],
365+
users: []
366+
}
367+
})
368+
369+
return jsonResponse({
370+
ok: true,
371+
delivered: true,
372+
changedCount: digest.changedCount
373+
})
374+
}
375+
376+
export const handlePublisherAbuseDigestApiRequest = (
377+
request: Request,
378+
client: Client
379+
): Promise<Response | null> => {
380+
const env = getRuntimeEnv()
381+
return handlePublisherAbuseDigestApi(request, {
382+
token: publisherAbuseDigestApiToken(env),
383+
trustedOrigins: publisherAbuseDigestTrustedOrigins(env),
384+
fetchChannel: (channelId) => client.fetchChannel(channelId)
385+
})
386+
}

0 commit comments

Comments
 (0)