| 1 | // Anti-spam gate. Identity is verified upstream (id.reasonix.io); this layer |
| 2 | // decides whether a *verified* member may create content right now, using trust |
| 3 | // levels rather than per-post content scanning — so it stays cheap and the cost |
| 4 | // sits at the identity/first-post gate, not on every message. |
| 5 | import type { Member } from "./env"; |
| 6 | import { HttpError } from "./identity"; |
| 7 | |
| 8 | // Trust rises with real participation; each level unlocks capabilities. New |
| 9 | // members (0) are deliberately limited so a throwaway account can't spam. |
| 10 | export const TRUST = { NEW: 0, BASIC: 1, MEMBER: 2, REGULAR: 3, LEADER: 4 } as const; |
| 11 | |
| 12 | // Posts/day cap by trust — a soft brake on flooding from fresh accounts. |
| 13 | export function dailyPostCap(trust: number): number { |
| 14 | if (trust <= TRUST.NEW) return 5; |
| 15 | if (trust === TRUST.BASIC) return 20; |
| 16 | if (trust === TRUST.MEMBER) return 60; |
| 17 | return Infinity; |
| 18 | } |
| 19 | |
| 20 | // Auto-hide a post once this many distinct members flag it, pending mod review. |
| 21 | export const AUTO_HIDE_FLAGS = 4; |
| 22 | |
| 23 | const URL_RE = /\bhttps?:\/\/|\bwww\.|[a-z0-9-]+\.(com|net|io|org|cn|xyz|top|shop|vip)\b/i; |
| 24 | |
| 25 | export function containsLink(body: string): boolean { |
| 26 | return URL_RE.test(body); |
| 27 | } |
| 28 | |
| 29 | // Throws an HttpError when the member may not post. `minTrust` is the category |
| 30 | // gate; `body` enables the new-member link block. |
| 31 | export function assertCanPost(member: Member, opts: { minTrust: number; body: string }): void { |
| 32 | assertCanInteract(member); |
| 33 | if (isStaff(member)) return; |
| 34 | if (member.trust < opts.minTrust) { |
| 35 | throw new HttpError(403, "insufficient_trust", "You don't have access to post in this category yet."); |
| 36 | } |
| 37 | if (member.trust < TRUST.BASIC && containsLink(opts.body)) { |
| 38 | throw new HttpError(403, "links_restricted", "New members can't post links yet — this unlocks once you've participated a little."); |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // Reactions and reports affect other people's content and moderation state, so |
| 43 | // they require the same verified, unsilenced identity as content creation. |
| 44 | export function assertCanInteract(member: Member): void { |
| 45 | if (!member.emailVerified) { |
| 46 | throw new HttpError(403, "email_unverified", "Confirm your email address before participating."); |
| 47 | } |
| 48 | if (member.silencedUntil && member.silencedUntil > new Date().toISOString()) { |
| 49 | throw new HttpError(403, "silenced", "Your account is temporarily restricted from participating."); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | export function assertCanFlag(member: Member, postAuthor: string): void { |
| 54 | assertCanInteract(member); |
| 55 | if (member.email === postAuthor) { |
| 56 | throw new HttpError(422, "self_flag", "You can't report your own post."); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | function isStaff(member: Member): boolean { |
| 61 | return member.role === "admin" || member.role === "moderator"; |
| 62 | } |
| 63 | |
| 64 | // Whether a member's action is subject to the per-IP creation limiter. Staff and |
| 65 | // trusted members (regular+) skip it; new/basic members are always rate-limited. |
| 66 | export function rateLimited(member: Member): boolean { |
| 67 | return !isStaff(member) && member.trust < TRUST.REGULAR; |
| 68 | } |
| 69 |