diff --git a/apps/moderation/Dockerfile b/apps/moderation/Dockerfile new file mode 100644 index 00000000..ca0c3cbb --- /dev/null +++ b/apps/moderation/Dockerfile @@ -0,0 +1,46 @@ +# Moderation service (verdict API). Node + in-process Qwen3Guard via +# node-llama-cpp — the guard is the only model and it judges inside /moderate. +# +# debian-slim, NOT alpine (unlike apps/server): node-llama-cpp's prebuilt +# llama.cpp binaries declare libc "glibc", and musl is unsupported. Pinned to +# trixie rather than the bare `24-slim` alias because that alias still means +# bookworm and would silently flip the base OS and glibc under the native +# binaries when docker-node changes its default. +# +# Built from the REPO ROOT, like apps/server: +# docker build -f apps/moderation/Dockerfile . +FROM node:24-trixie-slim + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/moderation/package.json ./apps/moderation/ + +RUN pnpm install --frozen-lockfile --filter balatro-multiplayer-moderation... + +COPY apps/moderation/ ./apps/moderation/ + +RUN pnpm --filter balatro-multiplayer-moderation build + +ENV NODE_ENV=production +# Surfaced at GET /health so you can tell which commit is actually running. +ARG GIT_SHA=unknown +ENV GIT_SHA=$GIT_SHA + +# The model is deliberately NOT baked into the image — mount a volume at +# /model-cache and put the GGUF there. This is a DIRECTORY on purpose: the +# service loads the single .gguf it finds, so the filename does not have to +# match anything. Point GUARD_MODEL at a specific file if you keep several. +ENV GUARD_MODEL=/model-cache + +EXPOSE 8001 + +# Generous start period: loading the GGUF on CPU takes a few seconds and the +# first judgement warms the compute graph. +HEALTHCHECK --interval=15s --timeout=5s --retries=5 --start-period=180s \ + CMD node -e "fetch('http://localhost:8001/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +WORKDIR /app/apps/moderation +CMD ["node", "dist/main.js"] diff --git a/apps/moderation/README.md b/apps/moderation/README.md new file mode 100644 index 00000000..59d8b4a7 --- /dev/null +++ b/apps/moderation/README.md @@ -0,0 +1,127 @@ +# Moderation service + +A stateless verdict API for chat. One endpoint decides, the relay enforces. + +It has **no database and no per-player state whatsoever** — a request goes in, +a verdict comes back, and nothing about the message or the player is retained. +Two instances judge the same message identically, and a restart changes +nothing. Anything durable (evidence, mutes, bans) is the caller's business, +deliberately. + +Per-player rate limiting is the caller's business too: the relay's chat route +already limits each player, so a second identical bucket here would only +duplicate it. Overload is handled where it belongs — a global ingress valve, +and the guard lane shedding its own backlog. + +``` +POST /moderate {playerId, lobbyCode, message} + → {verdict: "allow" | "reject", band, latency_ms, + publishText?, reason?} +GET /health → {status: "ok" | "loading", model_loaded, enforcement, + auth, model, model_load_error, build, lists, guard} +``` + +`publishText` on an `allow` means **publish this instead of what was typed** — +a link stripped, mild profanity softened. The relay publishes the rewrite but +stores the original as evidence. + +## Running it + +The service is wired into the repo's compose file; from the repo root: + +```sh +docker compose up -d moderation +``` + +Then give it a model (see below). Nothing else is required — the word lists +ship inside the image, so a stock container is already configured. + +## The model + +The guard model is **not** in the image or the repo — it is far past GitHub's +100MB file limit. The service needs one to do anything. + +Put the `.gguf` in the `moderation-model-cache` volume, once: + +```sh +docker cp your-model.gguf bmp-moderation:/model-cache/ +docker compose restart moderation +``` + +The filename does not have to match anything. `GUARD_MODEL` defaults to the +`/model-cache` **directory**, so the service loads whatever single `.gguf` is +in there and logs which one. Point `GUARD_MODEL` at a specific file if you +keep several — with more than one present it refuses to guess and says so. + +The volume survives restarts, rebuilds and image updates — only +`docker compose down -v` clears it. + +Loading takes a few seconds, plus a first judgement to warm up. `GET /health` +reports `model_loaded`, and the startup banner states the posture it came up +in — check it before concluding anything about behaviour. + +**When enforcing (`SHADOW_MODE=0`), a missing model refuses every message** — +nothing unjudged is ever published. If players suddenly cannot talk at all, +check `model_loaded` in `/health` before looking anywhere else. + +**In shadow mode (the default), a missing or still-loading model publishes +instead**, marked `review` in the log. That mode already publishes everything +the guard would have blocked, so treating "cannot answer" more harshly than +"answered Unsafe" would only ever cost you working chat. The deterministic +tiers still enforce throughout, so this is never unfiltered. + +A model that is merely *slow* (deadline, backlog) refuses in both modes — the +model works there, that message just wasn't judged in time, and a retry gets +through. The line is whether a retry could ever succeed. + +Roughly 4 GiB of free RAM is needed to load it. + +### CPU + +Leave the container uncapped, and llama.cpp matches its thread count to the +cores it can see. Capping CPUs **without** also setting `GUARD_THREADS` to the +same number is the one configuration that fails badly rather than gracefully: +llama.cpp threads spin-wait, so oversubscription collapses throughput (a ~2s +judgement becomes 20–40s). Change both together or neither. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `GUARD_MODEL` | `/model-cache` | Model file, or a directory holding exactly one `.gguf`. | +| `SHADOW_MODE` | `1` in compose | `1` = log what the guard *would* block without blocking. `0` = enforce. | +| `GUARD_THREADS` | all visible cores | Only set this alongside a CPU cap — see above. | +| `MODERATION_BEARER_TOKEN` | unset | Optional `Authorization: Bearer`. The service publishes no host port, so a network boundary already protects it; set one before exposing it. | +| `PORT` | `8001` | | +| `ALLOWLIST_PATH` / `REWRITES_PATH` / `APPROVED_DOMAINS_PATH` | bundled copies | Override the shipped word lists. | +| `MODERATION_REQUIRE_LISTS` | `0` | `1` turns an unreadable configured list into a startup failure instead of a silent degrade. | + +`SHADOW_MODE=1` only relaxes the **model** tier — the deterministic tiers +(threats, blocklist, PII, rewrites) enforce in both modes. It +also covers a model that is absent entirely (see above); a model that is +merely late still refuses even in shadow mode. + +## How a verdict is reached + +Tiers run in order of how *certain* they are, not how severe — the cheap +certain checks settle most traffic before the model is asked anything: + +1. **transform** — strip unapproved links, apply rewrites +2. **threats** — explicit violence, always blocks +3. **allowlist** — a fast-pass for known-good phrasing; about 60% of real + chat short-circuits here (`good luck have fun`: 1301ms → 1ms) +4. **blocklist** — slurs and the like +5. **PII / contact exchange** — safety, not manners +6. **guard model** — everything still undecided + +A message that reaches the end without a usable guard verdict is refused — +except an absent model in shadow mode, which publishes as `review`. + +## Tests + +```sh +pnpm --filter balatro-multiplayer-moderation test +``` + +The pipeline is a pure core: every tier is unit-testable with plain strings +and no model, no network, and no fixtures. diff --git a/apps/moderation/config/allowlist.txt b/apps/moderation/config/allowlist.txt new file mode 100644 index 00000000..d648c910 --- /dev/null +++ b/apps/moderation/config/allowlist.txt @@ -0,0 +1,5105 @@ +# Data-derived allowlist (tier-0 fast-pass) — generated from a 685-day chat +# export, most frequent first. 5091 entries. +# One NORMALIZED message per line (see normalizeForAllowlist). Lines starting +# with # are ignored. Every entry was deterministic-clean AND judged Safe by +# Qwen3Guard — review before shipping; delete any line you dislike. +gg +glhf +ggs +hi +yo +gl +hello +hey +sure +gl hf +yeah +glgl +ok +lol +? +oh +u2 +damn +howdy +sup +yes +heyo +ggwp +yoo +hii +lmao +good luck +no +yea +wp +ah +sorry +ye +xd +same +yep +o/ +okay +heya +alr +yoyo +ello +nice +gg wp +wow +you too +hf +np +nah +kk +hello hello +cocktail +ghost white +bet +yooo +helloo +cool +wait +hiya +all good +hihi +😭 +hello again +i see +gl gl +ty +haha +idk +thanks +well played +oops +nope +bruh +yup +yo yo +hiii +hi again +aight +what +glhf! +huh +alright +no worries +k +bro +white +glhf 🙂 +sounds good +unlucky +oof +mb +wtf +dang +what happened +ahh +true +heyy +gl hf 🙂 +hmm +ggs tho +random +ggs man +yeah sure +thx +have fun +oh ok +ohh +well +fr +i did +hahaha +yoyoyo +hi hi +uh +?? +ahhh +me too +hellooo +nvm +ohhh +helo +well gg +hey hey +one sec +fair +🙁 +wsg +whats up +rematch +glhf +weird +g +im down +omg +ghost +makes sense +u too +ggs wp +my bad +🙂 +stake +yoooo +wsp +run it back +random deck +damn gg +welp +well ggs +holy +wassup +oh no +hfhf +hi 🙂 +😄 +ya +u 2 +what did you have +sry +lmfao +hallo +gg's +wanna do cocktail +fair enough +gg man +oh damn +oop +hmmm +gotcha +gl hf +uhh +my game crashed +crazy +thank you +wdym +uhhh +oh wow +okok +good game +. +all g +interesting +lets do it +maybe +hey again +??? +lets go +how +yeah lol +oh well +ill join +... +heyhey +oh lol +gg bro +sweet +oi +ggs bro +tyty +again +unfortunate +i think +gg tho +./random deck +i mean +so +o +yeah same +hello there +ikr +glhf ! +up to you +happens +goodluck +like +yooooo +yessir +nw +ohhhh +yeah gg +heey +idc +hell yeah +no problem +gll +same code +>randomdeck +indeed +you there +random random +got it +hey 🙂 +crazy seed +welcome back +glhf :) +1 sec +ah ok +for sure +awesome +glhff +not really +dude +whoops +no way +cocktail white +oh wait +white cocktail +dam +oh i see +hm +what up +hihii +ready +im in +👍 +glhf :3 +oh yeah +idm +gg lol +hiiii +ahhhh +ggss +gl next +dc +thats crazy +alrighty +f +ah i see +glhf!! +code +close one +oki +yeah ggs +why +gg +hahah +👋 +perfect +ggs ! +right +what were your jokers +yh +nooo +tough seed +yeahh +good +dw +ight +helloooo +ofc +ok glhf +i know +man +um +its fine +what jokers did you have +that sucks +hi there +glhf! ❤️ +ayo +ggs +hello :) +damn ggs +yuh +what stake +hey mate +but +ouch +cya +mhm +ok cool +sorry about that +my b +gg mate +:( +u can pick deck and stake +oh okay +white ghost +ggs though +fun seed +what did u have +what deck +real +reroll +wpwp +there we go +fun game +hey there +hahahaha +wait what +game crashed +deck +great +heloo +ik +! +uh oh +yikes +down for cocktail +i think so +you good +sad +gl & hf +gl 🙂 +❤️ +yee +lmaoo +good luck have fun +its ok +w +yo 🙂 +oke +sick +woah +wanna run cocktail +gl +hii :3 +yea sure +it happens +noooo +ggg +you +sadge +glglgl +o7 +it is +im back +did you dc +yeahhh +sure why not +back +i can +oh nice +alr glhf +wanna play cocktail +hows it going +yo yo yo +yay +brb +what up homes +zodiac +ready up +hlo +gg 🙂 +hola +yello +what a seed +yeh +ggs lol +halo +you host +we meet again +damnnn +allo +restart +last shop +u host +okk +whatever +really +probably +glhf:) +cocktail deck +bans +🫡 +im sorry +^ +i didnt +orange +lol gg +shop +go +same deck +lmaooo +yellow +here +hi man +sure thing +ill join u +oh sorry +but ggs +so close +done +ig +heyooo +y +u there +btw +gl hf :) +same to you +same lol +good luck and have fun +smh +anyways +okey +down +i guess +anyways gg +close +im ready +i gtg +wanna rematch +fine +me neither +whats good +hi! o/ +yoooooo +wow gg +word +yeye +:) +loll +ooh +hold on +i dont mind +that makes sense +wanna run it back +wanna do random +good luck mate +idol +also +same here +hello 🙂 +close game +glh +up to u +anyways ggs +ffs +hahahah +wraith +srry +gimme a sec +nooooo +cool cool +exactly +anyway gg +gl and hf +hmmmm +gg! +g;hf +hai +heyyy +im cooked +hehe +uhhhh +ok sure +erm +works for me +:/ +oh nvm +oh mb +glfh +ggs! +gl! +ugh +ooo +ggs ggs +tf +rough +greetings +glgl :) +glhf ^^ +that was fun +u good +why not +yeah me too +lo +gg\ +what happend +wanna do white stake cocktail +i got it +ok gl +vote +ope +glll +anyway +heyoooo +well well well +oh my god +ye sure +yah +ohhhhh +fire +best of luck +yeah for sure +hello! +gl hf 😄 +:3 +;-; +agreed +yt +i dced +cya around +it was +best of luck and hf 🙂 +wth +send code +insane +ur good +wp wp +ill join you +dang gg +ahh i see +:d +glhd +thats fine +same thing +2 +i +in shop +umm +give me a sec +black +oh what +glglhfhf +sorry man +hi 👋 +i am +lolol +im still in the lobby +good luck have fun :3 +glhf :d +jesus +what a game +😂 +sec +mmm +gs +okie +any preferences +oooh +reset +gl on the next one +weird seed +yess +bye +its okay +nws +did you crash +ggz +wanna do cocktail deck +plasma +ok ok +ahhhhh +skip +gg well played +hello!! +gl hf! +u +me +showman +can we cancel +i disconnected +alright glhf +oh hey +gl bro +fs +gl hf man +heyoo +cheers +yeah true +whatever you want +tragic +no skip +please +welp ggs +yeee +ghost deck +you dc +npnp +haii +h +u2u2 +yeah.. +same lobby +oh right +pls +hi +ey +cancel +welp gg +im so sorry +what jokers did u have +xdd +np np +oh god +idk why +hello +its all good +you can pick +yeah haha +likewise +smart +sounds good to me +ah damn +invis +valid +take your time +helllo +nothing +ggsss +join +bloodstone +yeah makes sense +hello friend +wanna white cocktail +gtg +im here +???? +ooof +no thanks +you can choose +noo +u wanna pick a deck +but gg +v +allg +good luck 🙂 +wbu +glgl hf +have a good one +3 +ok then +you disconnected +anyway ggs +yeah i did +lobby does not exist +if u want +hi :) +ty ty +glhfff +gl ! +i agree +oh dang +omw +fml +waddup +oh hi +hbu +yeah lmao +in the shop +dusk +yo yo 🙂 +^^ +ngl +thats weird +if you want +<3 +ggs mate +tough +did u dc +golf +wrong deck +ghost deck white stake +sure sure +:p +yur +nop +hiiiii +i do +🔥 +gg' +gg though +crazy game +qq +what do you want to play +eh +gl again +i messed up +or +are you there +ah nice +i join +hey yo +🙏 +ah okay +lets run it +that was close +brutal +oh hi again +flush house +hey:) +thats rough +hey +oh lmao +can you host +💔 +where +classic +yea lol +of course +and +thats insane +wsg lil gurt +but yeah +i dont know +ew +elo +ok bet +i lost +ahh ok +oh man +yeah fr +i see i see +yeah fair +yeah im down +ah yeah +bummer +i'm down +oh rip +wild +i won +nice game +what were ur jokers +glhf ❤️ +you 2 +lets do bans +glhf' +glglhf +/config default-deck-bans +yippee +👀 +oh shoot +uhm +no idea +woops +ghost white stake +wanna do ghost white +so sad +gga +s +wha +oh sure +ripp +yeah yeah +u2! +ancient +oh boy +gulp +was fun +ff +ill host +ill join urs +rare skip +fairs +glhf again +mm +we can +1 +oh cool +ic +gkhf +you win +tbh +yeahhhh +i dont think so +what do you wanna play +im good +wanna do random deck +random or bans +gggs +i did too +sounds fun +a +ah rip +lol yeah +no prob +last ante +unfort +vc +wut +yeaa +im down for whatever +wait a sec +i forgot +hey man +wym +gg gg +helooo +hh +ggs 🙂 +very close +oh alr +alr gl +noooooo +but like +frfr +ah fair +🤣 +can u host +hi! what's up +lets run it back +now +i did not +ggs\ +alg +you as well +hu +bet bet +ong +not sure +wanna do white cocktail +fun +lmaoooo +oooo +bans or random +insane seed +glhf! :d +ggd +alr bet +let's do it +see ya +ill join yours +orange white +ez +ah gg +random deck white stake +zodiac white +finally +ummm +you too 🙂 +you pick +photo +wat +wanna do white ghost +actually +thats fair +there +any +i have to go +lol ggs +wanna reroll +i had +painted +hello again lol +ah ggs +omfg +can u create the lobby +hey whats up +same deck and stake +sorry bro +ok gg +l +ahaha +wanna pick a deck +yes sir +green +ok thanks +hell yea +i can host +red +that's crazy +hi! +helllooo +strange +lobby doesnt exist +you can pick deck +true true +absolutely +gg i guess +all the best +gl on your next game +yes please +ready when you are +well glhf +what was your build +glhf bro +oh gg +what u wanna play +ankh +coolio +ggs again +/randomdeck +100% +want to play cocktail +yeah it was +crash +very +brooo +no no +you can pick anything you want to play +meow +e +go for it +yeah np +geegee +wb +gotchu +d: +u win +oh yea +gg :) +oh really +i didn't +oka +i cant +blueprint +got a deck you wanna run +you can pick anything youd like to play +bro what +u can choose +you can pick deck and stake +you choose +ello ello +yeah sorry +mime +aight glhf +nahh +we can cancel +you won +welcome +glhf <3 +yp +i misclicked +how are we +idk what happened +okkk +i have +xddd +rematch time +long time no see +😔 +did your idol hit +yo o/ +lolll +.... +what do u wanna play +i threw +thats unfortunate +what would you like to play +joining +great game +ill make +okay glhf +for me +sure lol +hi:) +😛 +ah gotcha +any deck +ggs anyway +crashed +🥀 +i dc +ah well +ho +hold up +its fun +yea gg +haha yeah +gold +nebula +bett +good luck! +sheesh +rdy +mbmb +im so cooked +yooooooo +what deck u wanna do +hi o/ +gg lmao +wow ggs +ggs well played +did you disconnect +1 min +not much +nah its fine +jeez +ok lol +na +brainstorm +any pref +magic +whats that +i'll join +okie dokie +white stake ghost +u dc +fair fair +will do +i gotchu +gg ig +very nice +gg ^^ +oo +glhf* +so sorry +hah +lets cancel +:l +yes yes +noice +suree +gold stake +hellow +yo glhf +gl man +cocktail white stake +baron +violet +im still in lobby +just cancel +glhf brotha +we can run it back +oh my +lucky +ok np +let's go +no lol +yeah fs +yepp +wait no +deal +sigh +choose deck and stake +gl and hf 😄 +aw +yeah i know +gg!! +okay cool +it was fun +what deck u want +yea same +chad +i cant see +gggg +i gotta go +you sure +henlo +tysm +aha +abandoned +nice nice +down for white ghost +hi hf +ggs dude +darn +wrong code +erratic +nicee +ok ty +lets see +i guess so +rematch +thanks man +naw +okayy +aww +bruhhh +dayum +you can +yeah idk +dont worry +ggs anyways +any preference +oh wtf +ghost? +elloo +we can restart +misclick +wp tho +good luck, have fun! :d +but sure +yeah thats fine +cocktail +i will +idrc +hi again lol +take the win +wpgg +too +thats it +you can choose if you want +wanna do cocktail white +gl mate +i hate bloodstone +best of luck and hf +😮 +always +idk man +thanks for the game +dang ggs +aw man +what happen +ill make lobby +gg anyway +yo again +we can do cocktail +what deck do u want to play +u can host +yeaaa +yow +nevermind +that works +it +you can host +alr alr +thats the code +im new +chill +u2 🙂 +buh +well done +u pick +nono +yo wsg +anything +seltzer +no clue +you dced +jokers +pick any deck +good luck! :d +sure im down +ohhhhhh +random again +my game just crashed +gl too +thanks you too +glhg +ty u2 +gg] +gl;hf +t +ggs lmao +bello +pretty good +one moment +you to +i sold +sock +yesss +bottom decked +rippp +gllhf +heyyo +yeah lets do it +sr +lmao gg +i'm in +oh my bad +lol ok +wanna go again +glhf 😄 +merry christmas +can do +no rush +checkered +try again +what u wana play +how's it going +alright then +literally +g; +thank god +hellooooo +surely +i dc'd +phew +good morning +hi\ +glgl! +gl boss +happens to the best of us +oh alright +you can pick the deck or we can random +ggs' +i have no idea +oh interesting +all good lol +damn it +let me check +it's fine +nahhh +hoi +round 2 +hopefully +ahhhhhh +rematch lol +lets gooo +my idol missed +i allow 1 veto btw +start +idol missed +yeah why not +respect +okii +your choice +happy new year +in +yeah that makes sense +what deck you want +to +wanna play cocktail deck +honestly +sup man +that +what deck u wanna play +oooof +flush five +wyw +hey!! +good luck goat +play +yeah ik +its a bug +what the hell +which deck +lobby +aight bet +first game +anaglyph +what'd you have +fine by me +dna +aye +facts +u here +doesnt matter +i got lucky +wanna restart +ghlf +lets do ghost +ggs xd +ban +heh +mmmm +seance +thnx +yea ggs +i dont remember +perf +.. +loool +yes pls +ill make the lobby +gl have fun +ggs ig +ggs!! +kinda +!! +gls +random +howdy 🙂 +u down for cocktail +well gg wp +what jokers +howdy howdy +i dcd +yo waddup +wello +join up +which one +yeah xd +ahhh i see +run it +:c +glglglgl +just a sec +that was a fun seed +i dont care +glass +wanna do random random +whatd you have +u can pick +twice +lets do cocktail +zamn +hf :] +* +glhf my friend +glglg +broo +fun one +ggs indeed ++ +hiiiiii +im not sure +ill make it +same lmao +we can rematch +we'll see +gn +trust +im so dumb +sorry for the wait +hellloo +ahahah +so yeah +glhf 🩷 +brother +oh oops +im in the lobby +golden ticket +good luck and have fun 😄 +yo! glhf +sup gamer +im down for anything +i skipped +what do you mean +its bugged +i saw +hows it goin +😉 +tyy +appreciate it +hello o/ +yeah i think so +yeah sounds good +have a nice day +ggsd +yeah probably +huge +u wanna play ghost white +hahahahah +gl and hf 🙂 +you too man +good luck, have fun +i didnt see it +yo! +joined +i had so much money +lets get it +still +r +youre good +you down for cocktail +did you +how do i do that +ok thx +thats cool +i like it +okay okay +heidelberg +good to know +u won +niceee +wanna cocktail +yeah exactly +sorry bout that +bruhh +so did i +oh well gg +no i didnt +have a good day +pick a deck +damn unlucky +oh weird +gm +vote pls +gl hf <3 +i'll join you +go ahead +easy +hehehe +glhfd +oh.. +yer +this +im not +i can wait +supp +you make lobby +i got disconnected +same same +lets do white +kay +love it +white +sound +hello bro +glf +ye gg +hey what's up +i got kicked +whats the code +dawg +odd +yeo +u sure +says lobby doesnt exist +gk +cancel match +it is what it is +blue +how are you +disconnect +i hate idol +gl in next +uhhhhh +idol didnt hit +oh hey again +aight gl +fr fr +hmmmmm +never +yes lol +lets do random +for real +yeah i saw +that was so close +take care +you create +i joined +stake bans +sorry lol +glhf\ +mine too +my fault +thank u +red seal +ok nice +ok gl hf +lets ban +ggs i guess +:(( +rand deck +im down for cocktail +or nah +any prefs +or cocktail +1s +are you down for cocktail +here we go again +ooooh +wsp gamer +salutations + +# --- Guard-flagged high-frequency candidates (0.6B false positives on game --- +# --- vocabulary). Human-review: uncomment any line to approve it. --- +rip +white stake +i crashed +damnn +rough seed +random white +damnnnn +damm +black stake +dope +hell ya +white is fine +i cant see your jokers + +# --- v2 additions (2026-07-09): teacher-Safe, count>=15, deterministic-clean +# --- (excludes slurs/links/rewrite-words/discord-artifacts). 3605 entries, +126,448 msgs. +hey! +ghost white? +white stake? +white? +what happened? +you too! +hello? +random? +have fun! +rematch? +stake? +run it back? +random deck? +sure! +what did you have? +gl hf ! +./random deck? +howdy! +glhf !! +what? +💀 +random random? +you there? +ggwp! +dc? +glhf!!! +code? +huh? +gg ! +ggs !! +what were your jokers? +ready? +what jokers did you have? +what stake? +white ghost? +what deck? +reroll? +deck? +you good? +you? +did you dc? +hiya! +wdym? +thanks! +hf! +heyo! +restart? +u host? +really? +goodluck! +same deck? +again? +u there? +good luck and have fun! +zodiac? +heya! +why? +wanna rematch? +no? +wanna run it back? +wp! +wanna do random? +gg? +yo? +gl!! +u good? +what happend? +how? +cya around! +what did u have? +gl and hf! +same thing? +orange? +oh? +any preferences? + +reset? +gl on the next one! +did you crash? +same! +same? +well played! +hello!!! +skip? +u? +gl hf!! +no skip? +ghost deck? +you dc? +yeah... +hi there! +cancel? +hi ! +hey hey! +hello ! +u wanna pick a deck? +random white? +gl !! +thank you! +bans? +u too! +all good! +did u dc? +can we cancel? +ghost deck white stake? +all good? +are you there? +plasma? +hello again! +yellow? + +nice! +hey ! +can you host? +ty! +ghost white stake? +you host? +wanna do ghost white? +u2!! +rare skip? +good game! +wanna do random deck? +random or bans? +vc? +hi! what's up? +can u host? +what do you wanna play? +sup! +black stake? +bans or random? +hm? +random deck white stake? +orange white? +zodiac white? +wanna do white ghost? +good luck have fun! +wanna reroll? +shoot +same deck and stake? +wanna pick a deck? +hi!! +gl on your next game! +what u wanna play? +what was your build? +cya! +black? +crash? +got a deck you wanna run? +<:idolcult:1344479613614559273> +did your idol hit? +what do u wanna play? +what would you like to play? +sounds good! +good luck!! +no worries! +ok! +ggs tho! +any pref? +did you disconnect? +what do you want to play? +white stake ghost? +u dc? +gold stake? +bro? +rematch! +what jokers did u have? +what were ur jokers? +gg!!! +down for white ghost? +you sure? + +hi again! +rematch ? +ghost?? +hii! +yes! +any preference? +best of luck and hf! +<:drspec2wave:1287806525892460607> +now? +gg wp! +jokers? +vote? +yeah! +ggs wp! +ello! +okay! +hbu? +wbu? +same code? +random again? +idol? +right? +white stake ? +wanna go again? +gl gl! +glgl!! +you can pick the deck or we can random. +wyw ? +hey!!! +ggs? +which deck? +first game? +same to you! +lobby? +what'd you have? +wanna restart? +u here? +yeah? +gls ! +ggs!!! +random ? +what jokers? +wello! +have a good one! +<:absolutecinemacinema:1427998992280850472> +ok glhf! +yo!! +gg. +<:bonk:1427748162139066449> +u wanna play ghost white? +good luck, have fun! +what do you mean? +did you? +u 2! +hai! +hello friend! +good? +white ? +you make lobby? +hey what's up? +oh... +hi? +whats up? +how are you? +ready up? +disconnect? +u sure? +rand deck ? +yes? +stake bans? +hows it going? +yep! +cancel match? +you create? +any prefs? +you here? +did you leave? +yeah thats fair +i join u +i will join +ah unlucky +im scared +😢 +tho +ghost white ? +well played man +isee +watcha up for? +wp gg +based +im so confused +ggssss +oh shi +i took it +gee gee +hiya!! +nooooooo +hello new friend +me too lol +yupp +gl :) +good game 🙂 +okay then +random stake? +sure 🙂 +gl hf:) +what is that +oh nooo +:)) +go again? +hahahahaha +holy moly +glhf1 +u choose +ah makes sense +one more? +ok? +noooooooo +): +whatsup +i'm ready +lovely +yeah ok +then +pref white stake +glhf man +want to run it back? +ops +gi +gold? +see +so like +gljf +gl* +lowkey +ggs tho wp +that was rough +ohh ok +so true +not yet +i sold it +your call +hewwo +alo +oops lol +peak +damn lol +who won? +yeah no worries +haiii +ahh okay +interesting seed +u can pick stake +aaah +uh sure +glhf!!!! +we can do random +u make? +hello sir +gradient +gl hf !! +game +same deck/stake? +what deck do you want? +glgl 🙂 +aint no way +we go again +no money +i didnt take it +lobby code +idol hit? +take ur time +high card +wanna random? +tough game +glhfg +lets goo +you played well +i'm still in the lobby +oh dear +meh +dang it +we skipping? +same lobby? +hf +coming +lol same +either way +understandable +i was +d +np! +it crashed +white stake random deck? +yooy +which stake? +so bad +wanna do random random? +whats that? +or not +lmao yeah +gl next game +yeah maybe +its chill +hey hows it going +lemme check +oh true +sadly +join me +money +caps +jk +thanks for playing +yellow white? +one second +glhf~ +this one +gotta go +alr ggs +i missclicked +ok lets go +hard seed +ahah +cool! +you wanna host? +well played tho +i'll join yours +prob +steel +what's up +ok im back +you still there? +good luck man +best of luck! +thats tough +down for whatever +the rematch +i hate this game +i bottom decked +oh really? +i feel that +okay gl +there you go +im dumb +damn that sucks +oh hello again +same same? +sucks +want to go random? +wanna do bans or random? +damn rip +im down for wtv +didnt see it +god +pack +ggs :d +wanna do ghost? +you ready? +at the start +ah sorry +aah +hello\ +ah right +glhjf +wrong stake +weird game +wait sorry +4 +tyt +wp though +what deck you want? +hlhf +bruhhhh +-_- +idol hit +where was it +ill create +sure ig +what was it +thats why +lool +you can pick stake and deck +67 +yeah it is +its always a high card seed +wpp +whats cooking good looking +i need to go +☠️ +look at my deck +lets try again +good good +sorry for that +gg* +we back +it did +fr? +hallooo +🥲 +yea yea +glhf mate +ecto +ghost is fine +hell nah +i cant see ur jokers +ji +gl gl gl +wtf is this seed +ggs then +should be good now +sixth sense +certificate +gllll +ohh i see +got too greedy +flush 5 +,/random deck? +ok sounds good +you joining? +i'm sorry +glhf 🔥 +it worked +wild seed +lets just cancel +i dont +what you have? +perhaps +yes i did +he +rip gg +nice one +ahh gg +hahahha +ggswp +nah all good +look +you're good +never saw it +epic +damnnnnn +i forgor +gl hf 😉 +unlucky man +glhf! :3 +ye ye +wym? +got a lobby? +yeah ofc +what hand were you playing? +ah lol +haha gg +no thank you +i got dc +holy seed +chillin +your good +or ghost +hiyaa +this ante +and dusk +we can do whatever deck/stake you want i like them all +im so stupid +almost +yeah i see +that was a crazy seed +neat +thats wild +same again? +any deck you want to play? +i knew it +yay! +can you vote? +gg brother +g'day +wanna play again? +i don't think so +thats ok +same for me +could be +ooooo +bet glhf +hi glhf +too greedy +hry +whats uppp +cryptid +yeah. +tru +here we go +that was a fun one +alr gl hf +you got a lobby? +white stake good? +oh noooo +but still +ayoo +wraith? +oml +kk glhf +and stake +it does +you wanna choose? +ghost ? +ahahaha +my goat +wp ! +apologies +up +you dced? +brutal seed +ill do it +nice seed +herro +try again? +whattt +we can do bans +damn bro +gl next! +suup +indigo +bad +alright ggs +what deck do you want to play? +wp ggs +i swear +ghost white again? +😭😭 +lfg +spectral +red deck? +you got it +or bans +nty +you can pick anything you want +glhf gamer +lmaooooo +ok im in +mmmmm +oh whoops +ripppp +wowww +probs +ggwp!!! +ill wait +x) +what happened ? +ahh nice +at all +dont think so +😼 +hihi :3 +lets goooo +that was crazy +you too!! +u can pick deck +pls vote +make sense +gl h +sur +you did +i had it +im an idiot +glhf :] +both +ayyy +ye same +i seee +glhf!) +what the +i cant see them +thx! +u ready? +hey yooo +it says lobby doesnt exist +hello. +sooo +cool glhf +what you wanna play +yea makes sense +showman? +its alright +looks good +rebate +create lobby? +gg dude +dangg +sure np +gl hf!!! +crap +thats unlucky +mannn +you can pick the deck +tough one +what was the build? +where? +i had idol +broooo +what hand did you play? +have to go +gl :d +yeha +you can choose deck +hi sorry +whatd u have +lmfaooo +im in pvp +kms +hey hi hello +ait +whats cooking +no worries lol +bug +thats good +perkeo +we can do that +the +thanks bro +oh no! +yeah fair enough +soz +danggg +glhv +hi' +glhf c: +alr then +white stake fine? +this is the code +u make lobby? +that's fine +this seed +you crash? +what happen? +nice gg +ah dang +i get it +can you create? +wp man +anyway wp +hey yoo +no sorry +okay sure +hi lol +mb mb +redo? +what were you playing? +k glhf +glhf :d !! +ehh +sup sup +early +you can choose the deck +yerp +haha ggs +hiiiiiii +hey hey :) +ever +mhmm +gg thank you +good luck bro +alr glhf! +ranked +guh +what were you running? +i dunno +ghf +black deck +si +yeeee +gg gl next +ggwp!! +glg +ok no worries +ggs gl! +game crash +okay sounds good +we will see +glkhf +alr gg +all good now +idk how +or random +no pref +stakes? +u2 mate +oh hello +0 +it bugged +opps +can we restart +hie +bettt +up 2 u +we good +can u create? +gl!!! +this? +this is my first game +idk then +ghost black? +my pc crashed +gg sir +do u have deck prefrence? +have a good day! +cant see your jokers +u can pick deck and stake if u want +damn nice +wanna try again? +?* +you left +looool +i was like +gg xd +you can have the win +i threw so hard +congrats +amazing +it be like that +i thought about it +erratic? +ello again +hi hi! +would you be down for random random? +u disconnected +i can't see your jokers +i'm back +glhf xd +hahha +abandoned? +you left? +can u make lobby +glhf👍 +yo!! whatever deck and stake you want is fine with me +you crashed? +not at all +wah gwan +did u crash? +at the end +this seed is crazy +alr cool +latest file +gg haha +what was your econ? +hey, cool with random deck and stake? +whoa +yeah ig +disconnected +works +and brainstorm +>randomstake +wait nvm +i had a lot of money +ggs fun game +1sec +can we restart? +so... +ayy +oh gotcha +you dc'd +so unlucky +sorry mate +hi:d +definitely +????? +pick +what's up? +or what +good luck again +what does that mean +yellow deck? +we can do white +lets do this +hey sorry +are you in pvp? +oh. +green stake +;p +woww +yes sure +you too :) +come on +just a moment +new code +im dead +u create? +been there +ill cancel +yep yep +can you make the lobby +rq +yote +yo wsp +plasma white? +ya sure +glhf! :> +lets restart +what is this seed +gl 🫡 +ggs~ +allright +let me restart +alright gl hf +this is my first time +yeah sure! +and chad +can you make the lobby? +you pick deck and stake +i can make it +pain +have a gn +white stake ok? +ok ggs +aight gl hf +sure lets do it +dced +:o +gl gl 🙂 +u2! :d +maybe not +oh fair +😅 +it's okay +lucky cards +good stuff +kk gl +lol all good +you can take the win +unlucky gg +we run it back +correct +ya ggs +i can make +uo +hiu +gj +yep lol +llo +pvp +eyo +i believe +which one? +ohhhhhhh +2.20 +ha +xdddd +🤔 +yeah i figured +missclick +the classic +very fun seed +no econ +.-. +see you around +all good man +ban stake +spicy +any deck preference? +yoyoy +what you wanna play? +u make +thanks, you too +hello) +what the heck +it says your profile isnt fully unlocked +i mean yeah +orange deck? +yessss +yeah i guess +hello :d +besto luck~ +i had nothing +sorry! +whats up! +ur choice +not bad +yeah no problem +ok good +i did it +thats sad +you got this +istg +fair play +u2 bro +oyoy +how do i do that? +i choose violet +waiting +it was close +or no +gimme a second +uff +gl then +hl +found it +shame +good hbu +i just got it +spectral pack +fine with me +last pvp +yellow white +that's rough +i had seltzer +it's ok +me 2 +basically +you wanna make the lobby? +sureee +good luck ! +so? +what did your idol hit? +green? +wanna do white stake? +ggs. +gl next match +what deck wanna use? +did i dc +ghost white or bans? +bru +ggs :) +do you have a lobby? +coool +its cool +sounds great +i figured +send it +nice deck +nah sorry +thats so sad +damn wp +5oak +frrr +oh ggs +ok sorry +pick anything ill play whatever +u joining? +first +rdy? +we can play whatever you want +my idol didnt hit +ah alright +x +agree +vagabond +dammit +glhdf +after +join mine +sr i dc +gold zodiac? +i got dced +multiplayer +lets go again +bang +try this one +wait a minute +i dont really care +who won +solid +you disconnected? +alright gg +seed +shi +wierd +wanna vc? +dam gg +ok wait +what now? +oh all good +doesnt exist +lollll +shoot the moon +oh well ggs +yeahhhhh +first time +a lot +ghost again? +yeah man +<:sendcode:786270027300077668> +glfh! +yeaaaa +alright! +hello~ +gooood luck have fun :3 +nothing much +gl hf :d +calm +lets do that +sorry dude +what happened?? +its not +alright glhf! +ticket +i love it +wanna reset? +too bad +lesgo +yea lmao +hang on +i win +ante 1 +glhgf +fixed +i banned oracle +ow +ghost it is +wanna host? +choose +ahh fair +gl with your next game +yeah that sucks +рш +mk + +did i dc? +i pick ghost +that's fair +pog +tuff +wanna do random deck random stake? +gg# +huy +🤷‍♂️ +yi +yours? +yeah we can +whatd you have? +okay bet +nm +im so mad +aight ggs +timer +suppp +oh haha +i don't mind +what a crazy seed +we can just cancel +scary +sure then +black gold +ggs] +hi :3 +unfortunately +lets go ghost +oh word +ggs haha +ggs my friend +have a good night +ah alr +first shop +ggs <3 +we can run it back if you want +awh +whats the issue? +early on +misclicked +standard pack +i left +where was blueprint? +nuh uh +dw about it +happy holidays +your pick +great! +i am sorry +that's weird +lololol +holy crap +we can cancel if you want +terrible seed +enjoy +yeah unlucky +baron mime +hi 😄 +question +ic ic +i never saw it +good job +same xd +oh xd +what deck do you want +yeap +u can +sure. +how tf +orange deck +doesnt work +forgot +yeppp +heyaa +hihii <:menherawave:1065624057095127080> +you know what +glhf lol +likewise! +balatro +something like that +double skip? +just curious +im confused +frick +red? +hio +veto? +no idol? +join back +u host or me? +u got it +😭😭😭 +somehow +me? +can you send the logs? +what is it +any deck in mind? +cmon +this is my first match +its alr +same haha +oracle +rejoin +i am in +its good +u dced +what deck u want? +whats the issue +you can pick a deck +heyyyy +oh sick +hi again 🙂 +white random? +that was a fun game +where was idol? +good game man +and photo +gimme one sec +oh dam +do you use the multiplayer launcher? +there ya go +aura +thanks 🙂 +hey :) +hell no +can you host please +oky +yuppp +sure yeah +i got you +lol sure +yeah one sec +can you vote +still says it +whatever u want +гг +down for ghost white? +uhh sure +sg +im so sad +wowwww +ahh gotcha +any deck prefrences? +oh thanks +gimme a min +white is good +off +i'll join u +shore +baron? +good luckk +want me to host? +:0 +my guy +yeah bro +well then +i hate it +campfire +glhf then +where was the red seal? +i give up +yeah wp +hihihi +less go +hello :] +lets play +neither +oof gg +is your profile fully unlocked? +hello ? +dont know +ggggg +nvm lol +r? +you as well! +last chance +glhf🤝👍 +i like ghost +wanna do ghost +yoyo! +ohhh i see +hmmmmmm +that'll do it +where was the brainstorm? +sure man +lock in +how so? +shalom +._. +you got lobby? +what decks +what ? +yh sure +type shit +he won +send +i feel you +yea im down +u left +random white stake? +i forfeit +i dc'ed +kings +i did? +bruh what +ah, i see +let's run it +rip ggs +anyways wp +black gold? +5 +ahh yeah +dice +hru? +nope lol +gg anyways +what a weird seed +lame +idk lol +me either +def +howdy again +bp +whattup +see u +me2 +damn wtf +glhf! :=) +yo gm +but idk +omggg +yellow deck +heck yeah +ehhh +you make? +> +dunno +nah nah +jello +lets do black +mb bro +im in lobby +my idol never hit +close match +u 2 ❤️ +yeah 😄 +for some reason +btw im on an alt so if u care about mmr we can cancel +deck and stake up to u +ggs ^^ +damn ok +ty you too +same bro +never played it +try this +op +my idol hit +ghost gold? +damn... +idek +what u wanna do +😭 😭 +when? +my god +sorry i have to go +tks +how so +oh sry +next time +<:galaxybrain:1430603436755521536> +can you make lobby +how does this work +ah cool +ah yes +game crash? +lol hi +yeah all good +hi bro +all good haha +truee +see you +same jokers +well, ggs +wow that was close +im easy +what was it? +whew +good game tho +ok glgl +that was tough +host +what did you find +down for ghost white or nah? +i am still in the lobby +you can pick whatever +ohh okay +sure idm +i dont understand +very well played +did +hey? +that was a close one +cant see ur jokers +ooooof +%appdata%/balatro/mods/lovely/log +haha nice +i fumbled +6th sense +:> +aaaa +black deck gold stake +oops sorry +ah well gg +start? +i trolled +i thought so +yea sorry +why not? +it was crazy +give me a min +there it is +gg ul +so uh +i had 3 +gl hff +its you +y2 +better +blueprint? +sorry wrong code +wait what? +n +ggsa +is +u2 ! +what is this +random is fine +glad +idk tbh +cancel pls +hf hf +gg c: +gg close one +glhffff +hrllo +not for me +thank +rough game +just in case +yoyoyoyo +u can pick the deck +ready when u are +rejoining +gl king +tnx +u2 :d +hi! what deck/stake would you like? +letsgo +crashed? +ill rejoin +sameee +wasup +intersting +anyway glhf +i voted +i am back +les go +you dcd +yeah that works +ye lol +soooo +econ +alr gl gl +hi again 😄 +okay lol +gege +one +everything good? +goof luck +anyway gg wp +crazy work +me to +choose a deck +new lobby +yw +im so bad +you hosting? +good now? +not +thats +it dced +guess so +bloodstone? +flush +glhf<3 +!randomdeck +bean +gl o7 +probably not +insane game +halloo +possibly +holy bottom deck +weh +no. +how ? +the code +so weird +gg !! +no problem! +i had 2 +u2 ❤️ +nahhhh +what you wanna do? +i had no money +that was insane +i respect it +good call +lobby doesn't exist +\ +super close +where was the red seal +is it? +hurry +пд +have fun ! +ghsot white? +gl in your next games +?????? +its crazy +im down for that +ofc ofc +cya round +whatttt +sir +yea ik +ok all good +where was brainstorm? +who knows +hi gamer +sure sounds good +yo whats good +sometimes +well gl hf +hfgl +give me one sec +i am so sorry +ola +oh noo +something came up +wanna play ghost white? +idk what that means +servers died, restart or tie +hru +ight bet +standard +i fixed it +you can choose deck and stake +t_t +again lol +servers flickered, theyre back now, either rematch or cancel +lul +i got greedy +it kicked me out +apparently +morning +damnit +ancient? +ok one sec +yeppers +did idol hit? +yoey +nt +well, gg +̄\_(ツ)_/ ̄ +hmm? +lets try +bad seed +have a nice one +g l h f +can you send logs? +yup yup +good! +we can do ghost +goated +any bans? +wow lol +another one? +oh for sure +just take the win +luck +gl hd +hey bro +afk +sorry gtg +it works +heidelberg? +go again +very fun +hf* +anyways glhf +where was the blueprint? +and bloodstone +bad luck +welcome back lol +wtv +hy +😆 +yeah it happens +what seems to be the issue? +white stake ghost deck? +maybe? +not me +well played though +fun match +yeah that's fair +okay thanks +oh! +hihi! +gday +ay +says lobby does not exist +all random? +yup! +got lucky +or random? +wp bro +gotcha gotcha +coctail? +i played so bad +that was fast +mate +my internet +can u vote +checks out +whats up boss +lol nice +what did you do +i didnt skip +what does that mean? +i sold so hard +do it +are you here? +glhf!! :3 +im fine with whatever +well ggs! +glass? +fair enough lol +hello hello! +plz +what did you play? +wait wtf +good seed +alrr +lghf +aite +lol hello again +are you in the pvp? +that explains it +im chilling +ill ban nebula +i’m down +well yeah +heres the code +yeah mb +i’ll cancel +no worry +gl twin +lets do it! +how's it going? +no kidding +i tried +sorry again +help +and idol +red seal? +ups +yuck +where was it? +heloooo +nah i didnt +oh wait nvm +i had no econ +i ff +glhf too +well gg man +alr2 +im in the pvp +it was in the shop +yup all good +tt +failed to parse +gl hf dont get fried +oh lord +thats what im saying +rr? +what deck do you wanna play? +didnt draw my hand +heard +will cancel +!!! +i did the same +thanks for waiting +did u +that was a good game +i suppose +lets do orange +😎 +ah fair enough +i skipped it +idk what that is +do u have a deck/stake in mind +sorry sorry +wrong profile +frr +speedrun +heyaaa +fun game tho +i don't know +lit +hellor +yoo wsp +stake ban? +ffst +ye fr + +you're welcome! +hear me out +im down for random +deja vu +nooooooooo +(: +was close +gg friend +i got +ok nvm +what deck do you wanna play +er +gg) +im good with whatever +id rather not +oh ye +wo +oh huh +yerr +gurt +ahahahah +yeesh +ill make a lobby +haha ok +ah nvm +err +aaa +gg, well played +nwnw +u2 u2 +hi hi 🙂 +mods +u left? +red deck +not too bad +create lobby +green deck +what deck u wanna play? +u got this +i'm here +same to u +yoyoo +i have no clue +ggs gl next +heyp +it still says it +this will be fun +i saw it +same stuff? +i forget +so much +oh my b +ank +u dced? +glhf! 🙂 +not working +as well +uhmm +run it back ? +yo what deck wanna use? +why did you leave +we can go again +lemme restart +hey mate! +xoxo +i believe so +thoughts? +ehm +guess not +what’s up +what now +i see that +yes plz +what deck you wanna play +so down +ok boss +good one +sorry abt that +aaaah +trueee +looks good to me +i know right +i just +fsfs +wrong vote +ggas +nah its all good +hhi +in a pack +yoooooooo +hey' +lets rematch +cool gl +my wifi +yop +gimme 1 sec +ahhh ok +thatll do it +i think i got it +can you create the lobby? +gg tho! +glhf homie +u wanna make lobby? +good to go +ah okok +ughh +i found it +on it +i got kicked out +ih +rn +down for random? +yeah same lol +awesome! +heeey +what just happened? +ggggs +okidoki +last round +high card? +wanna rematch ? +wtf? +haha no worries +jo +hell ye +lol glhf +helllo hello +thank you thank you +cant see +card +you dc'd? +hiiiiiiii +you want to go random? +yeah i am +i cant join +alright gl +ggs 😄 +alright cool +truly +im new to this +frrrr +yeah 😭 +i was cooking +can u make the lobby? +haha all good +yea np +2 secs +im fine with anything +icic +this game +did ur idol hit? +think so +yeah glhf +yeah i saw that +all good all good +idol never hit +didnt see +damn! +gh +good with me +u played well +yes it was +yea i did +what do we do +hi<:chubbers:413367937076953108> +lowk +not rlly +too late +can you vote please +what deck do you want to play +nah you good +same room +make a lobby +i got so lucky +lmao same +don't worry +nice deck fixing +u wanna host? +whatcha up for? +thats true +4oak +1-1 +i didn't see it +disconnected? +and sock +it was rough +yo bro +like wtf +no ty +samee +imo +3 random decks +but ok +im so down +1016c +no shot +so annoying +6 +heads or tails? +u2 :3 +aight gg +good lord +down for anything +i hope so +hello 👋 +my game is bugged +i can do it +seems good +dont forget to vote +first time? +goat +hey hey hey +cool beans +yurp +thats so funny +yeh sure +gl to you too +what about you +cool seed +gg indeed +what do we do? +sry about that + +see ya around +yeah rip +not even close +rip lol +alright good luck +interesting game +red seal steel +oh uh +faceless +i am ready +lets go! +no bro +we can play what u want +can u vote? +glhf :)) +what you got? + +nah im good +sure? +i’m in +ok im ready +fosho +gg that was fun +same tbh +mannnn +one more +u pick deck and stake +anyways, gg +lmao ggs +damn, gg +yeah same here +didnt hit +should be fine +send the code +wild game +golf! +we can reroll +so dumb +bro wtf +oh no worries +gl in next ones +trio +so real +hwy +awww +great seed +skill issue +sup again +hi vro +unreal +i might be cooked +hey again! +jajaja +ggs boss +i pick zodiac +what deck you feeling like? +yes. +vanilla +i have a question +magic? +now what +i'm down for whatever +i dont really mind +ok no problem +hello :3 +that was a tough seed +gg 😭 +but yeah gg +oh yes +figured +rematch 😄 +remake? +what did you do? +seriously +did u leave? +u did +yk +violet? +can we do white stake +its always high card +or something +you disconnect? +glhf again! +suo +ah man +ahoy +ahhh gotcha +glk +gambling +ghost white?? +i allow 1 re-roll +: ( +me too! +what deck do u want +should be good +soo +i didnt see +okay thank you +well well +thats the lobby code +you host or me? +oopsie +so idk +is the code +ggs man! +holyyy +>random deck +magic white? +best of 3? +sure, why not +lol hi again +damnnnnnn +very fun game +awful seed +just +what was your jokers? +mb lol +greed +i join you +i died +heads +yeah you too +glhf] +black deck gold stake? +but yeah ggs +glhf@ +good luck. +server died please restart match +test +yurr +hit +that works for me +idk what to do +copy +decks +yaaa +okay np +didnt know that +ah got it +not particularly +good choice +same 2 u +hullo +i like orange +flush house? +purple +i was too greedy +change the deck +wanna just run it back? +wooow +that seed sucked +haiiii +either way ggs +prolly +yo whats up +is what it is +ur call +bad draws +lets just play +yeah its fine +boring +geez +so much money +i was broke +how u doin +theres no way +whats up beast +v_v +huh weird +ok got it +gl have fun! +never mind +it was insane +thats brutal +ok lets do it +pretty much +no worries man +doing good +orange deck white stake? +wazzap +and seltzer +yeah okay +<:stuntmancult:1344479617120993342> +did your game crash? +well damn +ok fine +youre fine +glhf x2 +you can pick stake +nah bro +oh that makes sense +it's all good +that was +:))) +ggs close one +good luck!!! +not rly +well gl +it glitched +i already did +ok ready +rematch haha +no not really +any deck stake prefs? +gg then +white or black? +just in shop +i allow 1 re-roll btw +glhf friend +hello helloo +whats the code? +what do you have +its a glitch +w8 +for fun +all good bro +thanks for the help +gg btw +that was a good one +i'll wait +ill make a new one +yes it is +gl hf ❤️ +it says lobby does not exist +im joining +back again +well played ggs +omg lol +really close +ban stakes +ite bet +random deck random stake? +well ggwp +:v +yippie +not once +same as you +i saw that +from sixth sense +gg :d +anything else? +u create +it sucks +hav fun! +gl hf mate +ooops +i hate this +hellow :3 +well ggs man +im down for any +i got too greedy +gg close game +what do you want +merry christmas! +its up to you +got you +1 more? +third times the charm +yellow deck white stake? +you got the lobby? +thats so weird +yo' +ban decks +unluck +noted +first ante +i was so confused +lets do zodiac +got greedy +ig so +ahhaha +damnn gg +in pvp +wanna cancel? +lol rip +familiar +want to restart? +it kicked me +glass diff +no... +yeah me neither +ill do ghost +howdyy +its over +servers blipped, tie or restart, sorry +hello ^-^ +vanilla? +you make the lobby? +huh?? +did it work? +hello lol +try that +haha sure +im down for any deck +ty gl +we can rematch if you want +let me see +want to rematch? +gl hf again +heyooooo +just got it +i think so? +cant join +i remember you +i couldnt find anything +hi again xd +ill match +what deck you want to play? +ahh damn +hahahahha +u pick deck +or bans? +i thought i was cooked +gl in your next game +my +ok i got it +what stake do you want? +nah not really +good luck hf +gl again! +wtf is this +gg 🙁 +very wp +u crashed? +what are you up for? +much luck today? +i couldnt see +hey<:hugg:1112061017703321630> +ill pick ghost +oh yeah sure +ggs ❤️ +it didnt +nah its ok +i can make the lobby +which stake +as you want +is your profile unlocked? +thats awesome +cool thanks +okayyy +whaaat +fixed it +yea me too +u make lobby +daaamn +ban? +same lobby code +sure' +lolllll +danm +no idol +where was baron? +gl jf +gg ty +coolcool +it be like that sometimes +well hello again +the deck +holy deck fixing +u crash? +hiya u can choose +lmfaoo +naah +give me a minute +yo what's up +still? +are u here? +ahh ggs +wanna do zodiac? +you wanna host +gl <3 +rough one +cooked +fun seed tho +!logs +thats me +right right +legit +join the lobby +rerun? +yea haha +white pls +oh, ok +i just realized +0.2.20 +sorry i gtg +beautiful +ghlf! +and then +ah shoot +woof +depends +lets do yellow +trib +0.2.18 +can u create +bug? +yeah can do +incredible +been a while +ggs that was fun +riff raff +alr sure +oy +that hurts +gradient? +what were you going for? +it's alright +yo\ +im bad +this seed sucks +gg homie +yo yo! +p +yeah i'm down +once +what just happened +hi* +ggs brother +hilo +im fine with that +oh ty +oh okok +good play +ggs* +yeah, ggs +yaa +that's insane +whats up man +any deck is fine +i was cooked +cancel the match +i think you got me +poly +i love you +k im back +we can play whatever u want +it was in a pack +where was brainstorm +yea for sure +you again +gg i think +gl\ +yo sorry +you need to unlock your profile +gllg +lol what +good game though +ah no worries +thank youu +i did yeah +nicely done +hahaa +ok... +ggs brotha +bb +before +what was your setup? +didnt work +oh np +card pack +yo wassup +sure i guess +i am cooked +huhh +okay thx +your profile isnt fully unlocked +white or gold +ok thank you +what do u want to play +gggggg +okkkk +11 +alright, glhf +whatt +alralr +lets go random +missclicked +yeah i agree +damn ggs! +hello* +ante 2 +i just found it +awwww +sure ! +smallworld +its +yeah lmfao +do you want to make the lobby? +i hope +oops caps +same again +king +nice glhf +got disconnected +t-t +get me out +damm gg +and you? +and yeah +u can pick everything +thankyou +what were u running? +ggds +re roll? +ggs m8 +go easy on me +bet gl +what stake do you want +you too ! +thats smart +horrible seed +thats funny +hi there 🙂 +you make +any is fine +all +kek +oh jeez +should be +nah its good +that is crazy +hello again 🙂 +white stake random? +hard +you got me +yeah sure why not +im down for white +business card +wanna play a random deck? +g2g +ik lol +sweet! +i think im cooked +what deck wanna use +no wait +oh that sucks +invis joker +yeah damn +i was struggling +what was your lineup +ill copy +white zodiac? +wby +try +thx u2 +what was your jokers +lets not +preffered decks? +wanna reroll options? +what you have +hav funnn +that's unfortunate +casual +uh... +😏 +i did that +hellp +hello?? +second +sorry my bad +oh makes sense +wow! +white it is +servers blipped, cancel your match or run it back +u can choose deck +what you want to play +where was the brainstorm +black deck? +aighty +whats going on +tie? +any deck pref? +can you make? +you have lobby? +what did i miss +unlocked +my wifi went out +how r u +so lucky +shii +im in! +i did lol +wanna ghost white? +ahhhhhhh +and? +and steel +yea fs +ok yeah +i appreciate it +domo +best of luck and have fun 🙂 +difnn +happy halloween +and mime +miss click +can you provide proof of win via logs or screenshot? +idc what we play +im down! +later +looks like it +your profile is not fully unlocked +i thought i did +i guess not +ankh? +family +ty bro +i dont get it +holy hell +) +full house +if you dont mind +wait what happened +im done +oh, i see +random +ggs btw +10 +im waiting +unstuck +what happened lol +it says +ancient joker +oh hell nah +which +yeah thats rough +that was wild +haha same +alright lets go +m +where was blueprint +lol okay +skip or nah +i had 1 +2 sec +yeah it did +sorry for taking so long +youll see +good runs +nice comeback +im good with any +hate to see it +gl! <3 +oyyo +gl sir +okay gg +you can pick anything u want +helo bro +thanks tho +🤨 +ah that sucks +hello !! +thx you too +uhhh sure +oh fr +press ready +glhf^^ +that's cool +ty man +but yea +hey lol +holy gg +hallo! +i concede +what about you? +okyy +it was in shop +new one +dang lol +so we meet again +last game +that work? +damnn ggs +gimme a minute +what do you have? +i had like +already +annoying +oh i did +i'm so sorry +very close game +canio +stake ? +what did ur idol hit? +ggwo +sorry for the delay +red white? +good match +i missed it +you can start +well gg! +yours +wppp +i think i dced +hi. +my game crashed lol +and invis +glhf x +oh bet +enjoy! +what decks? +x2 +could you host pls +i can join +🥹 +did you skip? +bet! +ditto +i can make lobby +how do i join +what do i do +same here lol +ummmm +here you go +can we do white stake? +random deck and stake? +sure i'm down +u can have the win +gross +king of spades +omgg +painted? +seems like it +im pretty sure +how u doing +damn, ggs +same ? +deep +hello u can choose +no i did not +oh fr? +invis? +idk whats happening +not again +what are your jokers +deep in shop +sold it +what?? +true that +alrighty then +or cancel +are you sure? +dont remember +we can try again +ah ic +greeting +hi mate +damn. +did u crash +ggs gl in next +whoops lol +helloooooo +howdy <:peepocowboystanding:1353602305857159169> +cryptid? +hold +wrong one +and also +i think my game crashed +yeah my bad +jup +ite +try now +best of 3 +u can chose +what was your lineup? +😿 +i don't +wsgg +whelp +you make it +all gud +ty for the game +i got to go +i was lucky +het +sorry lmao +i wont +your turn +win +lets do ghost white +yeah prob +crazy seed lol +it happened again +skip or nah? +holy that was close +just in the shop +this is so sad +how we doin +ill brb +ok perfect +acrobat +ggs, well played +i had it too +what u wanna do? +oh hi! +i got u +idk either +again ? +white stake right? +ok done +like what +okay glgl +ahhh makes sense +c: + + +i can try +i do not +give me a second +nvm then +lets +oohh +yeah bet +no its fine +funny +doesnt matter to me +i made a mistake +yea ofc +yeah well played +lets have some fun +good idea +nah ur good +what were yours? +not really lol +nice lol +gg !!️ +egg +gl hf next +green stake? +or wait +can we cancel the match +that was weird +how are you doing +you ok? +rematch i guess +yep ggs +fun game! +didnt +better? +lol aight +what a shop +tyyy +are you still in the lobby? +i create +gglhf +ggsssss +ruh roh +dangggg +yessir! +ggs was fun +what deck would you like to play? +nice work +ah. +i see it +x3 +that was brutal +glhf 🫡 +none +hi gl +go on +elo! +what was ur lineup +for a while +ty gg +you can pick anything +dcd +what was ur build? +voucher +gg mr +alright thanks +lol no worries +im there +want to go again? +alrightt +random decks? +/config default-deck-bans. +yo what happened +you want to host? +heads or tails +sorry im new +host? +rats +can you? +and stake? +your profile isn't fully unlocked +yea fr +my bad lol +lhf +oh aight +sure idc +white stake orange? +yoi +hey you can pick anything you want to play +it hit +from where +just vote +is it good now? +whassup +ok good luck +white stake again? +where was ancient? +yeah hahaha +pick whatever +let's play +cuz like +what deck would you like to play +yeah unfortunate +where was the baron? +aight gl gl +no scoring +just ban +nah it's fine +oh, wait +rare? +ahh makes sense +sweet glhf +nah lol +ayooo +ill create the lobby +oooooh +was hard +drop da code +how we doing +gfhl +no like +yooooooooo +cinema +uh huh +hey\ +ul gg +woow +one minute +ok bro +still there? +trust me +sure* +lol yea +dangit +yaya +do you have the multiplayer launcher? +thats my bad +tails +you tooo +anw ggs +kk np +gg doe +or black +ag +bonjour +so uhh +judgement +checkered white? +glhf :> +rando? +yeah well gg +i just threw +oh unlucky +glhf!@ +in game +fl +lets go then +i was so broke +you have a code? +u can pick deck and stake im too lazy for bans +oh yeah lol +anyway, ggs +hello hello hello +im ngl +we shall see +sure bro +which jokers did you have? +hello again haha +what where your jokers? +what do u mean +stuntman +hye +oh ic +<:menherawave:1065624057095127080> +king of hearts +what stake u want +np lol +neither did i +hype +it should be +in ranked +missed my draw +wrong button +clearly +alas +glgl <3 +its like +but its fine +hi agian +helleo +thx u too +fyi +ghost/white? +gg 😄 +pause +run it again? +ban a stake +perfect! +checkered? +im stuck +sorry one sec +no lmao +ill try +same 🙂 +com +haha true +thats odd +gl hf then +swag +rematch ig +ill give you the win +very true +oh crap +u dc'd +gsg +what did you find? +yes sorry +i was so poor +any deck preferences? +stop +lol me too +death +supsup +no i did +lolz +where was bp? +u can chose deck +4x +mhm! +revenge time +brainstorm? +😐 +yu +its unlocked +because +ahhh right +yeah, same +veto +yeah lets go +yeah i mean +ty ❤️ +and blueprint +no probs +can we reset +alright have fun +straights +u right +tough luck +joining now +very sad +i meant +np man +jacks +the runback +hey glhf +bad draws? +what??? +idm what we play you can decide +ill play +lmfaoooo +its not working +redo +lol gl +lol fr +you do +but im down +want me to make lobby? +are you making the lobby? +sure white stake? +fixed? +why ? +ok word +well played gg +what are your jokers? +i'm happy with any deck +heyos ! +we can random +u hosting? +sup bro +what did your idol hit +sos +heya again +what did u do +what was the rare skip? +polychrome +me too lmao +thanks for the game 🙂 +have a nice day! +gg again +yeah nice +cool gl hf +gl, hf +you can choose stake and deck +did u skip? +for econ +what was your deck? +ggs homie +brotha +ahhh gg +impressive +gl hf bro +it's fun +gg my friend +no thx +ye np +restart your game +ghost black +rerolls +ahh unlucky +yeah def +we can restart if you want +ohhh ok +geegees +welp ggs! +i wish +lol true +i didnt know +ill pick zodiac +fein +wish you better luck with your next games +time +kewl +i mean sure +good game bro +my bad bro +very good game +thx gl +ready ? +oaky +stake* +i didnt draw my hand +its random +gl on the next! +can you make it +oh what? +can you +aces +yea i saw +when +yorick +thanks you too! +gl h f +sure haha +if you want to +i forgot to skip +hhs +but its ok +just play +hellllo +k, glhf! +i respect that +y tu +ill restart +peace +that seed +idk bro +<:jjkyujikillme:752743484691316797> +what did you had? +rip me +thanks a lot +shiiii +yeah happens +boo +ok, glhf +ok go +with dice +i'm good +gl fh +glhf! :) +shiii +betttt +skip or no skip +frl +i dis +yeah, gg +it says your profile is not fully unlocked +big fan +gl hf :3 +nice wp +ight gl +yy +ok ill join +no rare? +glhf-ings +eh sure +purple? +myb +lmao sure +make the lobby +can +join lobby +did you win? +you making lobby? +decks? +have a nice one! +ouhhh +any deck you want? +ah mb +deck and stake +i miss clicked +i like +who creates? +then idk +glglgl 🙂 +what stake you want? +yea true +aight then +thankss +how does that work +hello!!!! +u wanna do ghost white? +we can do orange +gl hf : d +how about now +hi) +what is happening +but also +alr thx +flhf +ok nw +пп +i like zodiac +yeah i can +not ghost +o.o +15 +white?? +what was your econ +im fine +thats okay +wanna do orange? +k gl +new lobby? +i can't see +yup lol +its ggs +ermm +mine +last one +yo!!! +working? +noooooooooo +nice build +as u like +wxaxo +aloo +hi sir +ggs gl +my game bugged +do u have a lobby? +would you like to play random random? +you can make the lobby +the order +anything is fine +no no no +you can make it +bro... +and plasma +yesssss +you pick deck +ah that makes sense +you too bro +on +do you want to host? +yessirr +i messed up so bad +plasma white +i will join you +white or black +gljhf +ggs unlucky +random stake too? +yeah definitely + +what is it? +that seed was rough +hi] +k cool +and dna +hey] +yyo +hellos +zodiac it is +wanne do white orange? 😄 +gl; +genuinely +that was a rough seed +it says your profile isn't fully unlocked +brooooo +i think so yeah +i understand +deaths +idol didnt hit? +wait a second +no bloodstone? +ggs# +okay lets go +no way lol +from wraith +ahh right +always a high card seed +lets bans? +o well +hi gl hf +you can pick any deck +run it back lol +man gg +but well played +but anyways +shit happens +gl in next! +ty u too +lol np +thats all +lmk +don't think so +hff +yeah i got it +idk maybe +i'll make it +o ok +thanks lol +hahaha yeah +you crashed +its my first time +ah wait +shall i host? +return to lobby +terrible +nice pfp +where did you find blueprint? +okay so +sorry caps +no dusk? +so fun +how come? +jfc +by accident +sure but no plasma +is ghost white ok? +pmo +you can choose any deck +your idol hit? +no stress +i was thinking +did you vote? +ok i guess +yoy +ahh that makes sense +randomdeck? +that one +gghf +🙃 +ggs though! +what an insane seed +that's why +any stake +gg close +uhhhhhh +wait huh +lets just do bans +play again? +u get the win +for sure! +hate when that happens +no i dont +ok 🙂 +urs +bom dia +pick any deck u want +fair lol +can we rematch? +alr sounds good +wtffff +uwu +what did you want to play +you wanna make it? +this is my first ranked game +gg wow +choose deck +take it +wassup! +only +everything ok? +is it bugged? +!log +g g +jokjz +oh goodness +you ok to host? +attrition +3 times +we can play +its my first game +i gotcha +yeah i will cancel +hllo +whats upp +sure lmao +give me 1 min +works now +lets go gambling +anyways, ggs +w name +its good now +hi!!! +and ancient +i sold everything +obelisk +you can pick deck and stake if youd like 🙂 +oh bruh +ill play whatever +haha yea +what does it do? +ahahha +white stake is fine +let’s do it +what jokers you have? +i got it last shop +barely +wait wait +bugged +yeah i see that +good question +rightt +did i? +yea... +ya lol +its funny +nice play +thanks u too +duh +u got a lobby? +smiley +ta +code doesnt work +thanks!! +purple stake +room code +yeah crazy seed +never seen that before +sorry 🙁 +judgement? +i had golden ticket +but whatever +you too :3 +but oh well +i was wondering +ahhh i see i see +hf 🙂 +gl1 +oh yeah for sure +steel? +was +bro 💀 +might be cooked +im blind +/random deck? +i c +can we play ghost? +logs +yo mb +good luck diva +join my lobby +take care 🙂 +htzqu +yeah, sure +can you make a lobby? +very cool +no worries at all +alr, glhf! +lets just do white +3rd times the charm +i noticed +hi :d +want me to make the lobby? +ggh +well good luck +you make the lobby +teehee +thats interesting +hey there! +last hand +i host +damn thats crazy +retry? +all good now? +gg gamer +hearts +sorry my game crashed +what did idol hit? +nah bans +to be fair +abandoned white +i have it +couldnt +i am not +got u +no luck +or zodiac +looks inside +😠 +gg fun game +gl' +ooop +rolling +well see +lets gooooo +shiiiii +etc +ill make a new lobby +try that one +gg that was close +errr +elooo +probably yeah +oh my days +yea idk +i didnt leave +i misplayed +i screwed up +or cancel? +luckies +yeaah +i go +glhf sir +white ok? +yeah ikr +oh nooooo +i was so scared +i had both +ok. +kind of +i mean like +chi +gg, well played! +ggwp tho +ill play whatever you want +what did u have ? +icl +give me 30 sec +yovnv +8 +hmmmmmmm +oh 😭 +whatever u like +👌 +idk whats going on +well glhf! +seems fun +yeah thats good +nuts +mine didnt +another? +yeah it was fun + !?️ +# Hand-added 2026-07-11: banter compliments the tuned guard misreads as +# Unsafe/Violent ("you destroyed me" measured guard_block in live testing; +# known tuned-v2 gray-zone weakness — also pinned as v3 training candidates). +you destroyed me +you destroyed me lol +i got destroyed +got destroyed +you wrecked me +i got wrecked diff --git a/apps/moderation/config/approved-domains.txt b/apps/moderation/config/approved-domains.txt new file mode 100644 index 00000000..96825e81 --- /dev/null +++ b/apps/moderation/config/approved-domains.txt @@ -0,0 +1,12 @@ +# Approved link domains — one per line. Any URL whose domain is NOT listed here +# is replaced with "[link removed]" before judging AND before publishing, so it +# never reaches other players. A domain also approves its subdomains +# (youtube.com approves www.youtube.com, m.youtube.com). `#` lines are comments. +# +# Empty (all lines commented) = strip ALL links. Add domains you trust below. +youtube.com +tenor.com +store.steampowered.com +giphy.com +twitch.tv +balatromp.com diff --git a/apps/moderation/config/rewrites.txt b/apps/moderation/config/rewrites.txt new file mode 100644 index 00000000..2ce634ce --- /dev/null +++ b/apps/moderation/config/rewrites.txt @@ -0,0 +1,21 @@ +# Community-vocabulary rewrites — applied to every message BEFORE judging; +# the rewritten text is what gets judged AND published. One rule per line: +# source => replacement +# source => replacement !! unless-regex +# Word-boundary, case-insensitive; the replacement is never re-matched. +# Use for game abbreviations the guard reads as crude no matter the context. +# +# The `!! regex` guard SKIPS the rewrite when the original message matches — +# for frames where the word is clearly anatomical, not the deck. The raw text +# then reaches the guard, which blocks it (measured Unsafe/Sexual). Without +# this, "suck my cock" laundered into "suck my cocktail" and judged Safe. +cock => cocktail !! (my|your|ur|his|her|their)\s+cock|\b(suck|lick|ride|stroke|deepthroat|choke|gag|grab|touch)\w*\b[\s\w]{0,20}\bcock|cock\s+(in|into|inside|up|out|sucker|pic) + +# "i will kill your blind" = BMP match taunt (the opponent's blind), but the +# tuned guard reads the first-person "kill your ..." frame as Unsafe/Violent +# (context injection can't fix it — tuned-v2 runs bare). "beat your blind" +# is judged Safe (measured 2026-07-10). Laundering is covered: a family noun +# after "blind" still threat_blocks post-rewrite ("beat" is in VERBS_B and +# the FAMILY regex allows two filler words: "beat your blind grandma" blocks). +kill your blind => beat your blind +kill ur blind => beat ur blind diff --git a/apps/moderation/package.json b/apps/moderation/package.json new file mode 100644 index 00000000..4051ae71 --- /dev/null +++ b/apps/moderation/package.json @@ -0,0 +1,29 @@ +{ + "name": "balatro-multiplayer-moderation", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check .", + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js" + }, + "dependencies": { + "node-llama-cpp": "^3.19.0", + "obscenity": "^0.4.6" + }, + "devDependencies": { + "@biomejs/biome": "*", + "@types/node": "^22.0.0", + "fast-check": "^4.8.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "overrides": { + "esbuild": ">=0.25.0" + } +} diff --git a/apps/moderation/src/guard/engine.test.ts b/apps/moderation/src/guard/engine.test.ts new file mode 100644 index 00000000..d6149ee5 --- /dev/null +++ b/apps/moderation/src/guard/engine.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { createFakeGuardEngine, createLlamaGuardEngine } from './engine.js' + +describe('createFakeGuardEngine', () => { + it('judges by the exact text of the last (sender) turn', async () => { + const engine = createFakeGuardEngine({ + 'you suck lol': { safety: 'Controversial', categories: [] }, + }) + + const result = await engine.judge([ + { who: 'sender', text: 'gg' }, + { who: 'other', text: 'lucky' }, + { who: 'sender', text: 'you suck lol' }, + ]) + + expect(result.safety).toBe('Controversial') + expect(result.categories).toEqual([]) + expect(result.latencyMs).toBe(0) + }) + + it('returns unknown for an unscripted message instead of throwing', async () => { + const engine = createFakeGuardEngine({}) + + const result = await engine.judge([{ who: 'sender', text: 'anything' }]) + + expect(result.safety).toBe('unknown') + expect(result.categories).toEqual([]) + }) + + it('is always ready', () => { + expect(createFakeGuardEngine({}).ready()).toBe(true) + }) +}) + +describe('createLlamaGuardEngine', () => { + // The generous timeout is the point: this is the only test that imports the + // node-llama-cpp native module, and a cold import on a CI runner routinely + // exceeds the 5s default. Failing here blocks the deploy for a reason that + // has nothing to do with the code under test. + it('fails CLOSED when the native module/model cannot load: not ready, judge throws, loadError set', async () => { + // No node-llama-cpp native binary/model is guaranteed present in this + // environment (CI, or a droplet before the volume is mounted) — this + // exercises exactly that fail-closed path without needing either. + const engine = await createLlamaGuardEngine({ + modelPath: '/does/not/exist.gguf', + }) + + expect(engine.ready()).toBe(false) + expect(engine.loadError?.()).not.toBeNull() + await expect(engine.judge([{ who: 'sender', text: 'hi' }])).rejects.toThrow( + /guard engine not loaded/, + ) + }, 60_000) +}) diff --git a/apps/moderation/src/guard/engine.ts b/apps/moderation/src/guard/engine.ts new file mode 100644 index 00000000..76917bd4 --- /dev/null +++ b/apps/moderation/src/guard/engine.ts @@ -0,0 +1,159 @@ +import { availableParallelism } from 'node:os' +import { buildPrompt, parseGuardOutput } from './prompt.js' +import type { GuardSafety, GuardTurn } from './prompt.js' + +// The context guard's injected seam (ws10). The heavy `node-llama-cpp` import +// is dynamic so this module is cheap to import (tests, other entrypoints) and +// so a load failure (missing native binary/model, e.g. in CI) is a caught +// state, not a crash. + +export type GuardJudgement = { + safety: GuardSafety + categories: string[] + latencyMs: number + raw: string +} + +export type GuardEngine = { + judge(turns: GuardTurn[]): Promise + ready(): boolean + /** + * The caught reason `ready()` is false (missing native binary, bad model + * path, OOM, ...), or `null` once loaded. Optional so existing test + * doubles need not implement it; main.ts logs it and /health surfaces it — + * "guard failed to load" with no reason is an hour of guessing instead of + * a five-second fix. + */ + loadError?(): string | null +} + +/** Minimal shape of node-llama-cpp's `LlamaCompletion` this module depends on. */ +type CompletionEngine = { + generateCompletion( + prompt: string, + opts: { + maxTokens: number + temperature: number + customStopTriggers: string[] + }, + ): Promise +} + +export type CreateLlamaGuardEngineOptions = { + modelPath: string + maxTokens?: number + temperature?: number + /** + * ggml worker thread count. MUST NOT exceed the cores actually available: + * ggml threads spin-wait, so oversubscription doesn't degrade gracefully — + * it collapses (measured 20-40s/judgement vs ~2s on the same 2-core host). + * Defaults to `availableParallelism()`, which reads the host's cores; in a + * cpu-quota'd container set GUARD_THREADS to the quota instead. + */ + threads?: number + /** + * Human-edited game-vocabulary notes (already parsed via + * `parseDomainContext`), injected into every prompt. Teaches the guard the + * game's slang ("white stake" = difficulty, not race) so in-domain banter + * stops false-flagging — measured to leave real-harm verdicts unchanged. + */ + domainContext?: string +} + +// NOTE on prefix caching (do not re-attempt without new evidence): manually +// pre-evaluating the constant prompt prefix into the KV cache and rewinding +// per judgement (evaluateWithoutGeneratingNewTokens + eraseContextTokenRanges) +// was built and measured 2026-07-07 against the plain generateCompletion path +// — it was SLOWER on both backends (GPU Vulkan: 187ms vs 119ms/msg; CPU: +// 5800ms vs 5110ms/msg; verdicts identical). node-llama-cpp's high-level path +// is already near-optimal here. The cheap lever for prompt cost is keeping +// guard-context.txt terse (every token is per-judgement prefill). + +async function loadRealCompletionEngine( + modelPath: string, + threads: number, +): Promise { + const { getLlama, LlamaCompletion } = await import('node-llama-cpp') + const llama = await getLlama() + const model = await llama.loadModel({ modelPath }) + const context = await model.createContext({ threads }) + const sequence = context.getSequence() + return new LlamaCompletion({ contextSequence: sequence }) +} + +/** + * Builds the real llama.cpp-backed guard engine. Loading is attempted once; + * a failure (no native binary, missing model file, OOM, ...) leaves the + * engine `ready()===false`. This is FAIL CLOSED, not fail-open: /health then + * reports not-ready, POST /moderate 503s (server.ts), and the relay fails + * closed on that 503 per its own outage policy — every non-deterministic + * message is rejected while the guard is down (decide.ts's `guard_unavailable` + * floor). A down guard is a total chat outage, not "less auto-resolution". + * `loadError()` carries the caught reason so main.ts and /health can say why. + */ +export async function createLlamaGuardEngine( + opts: CreateLlamaGuardEngineOptions, +): Promise { + const maxTokens = opts.maxTokens ?? 48 + const temperature = opts.temperature ?? 0 + const threads = opts.threads ?? Math.max(1, availableParallelism()) + + let completion: CompletionEngine | null = null + let loadError: unknown = null + try { + completion = await loadRealCompletionEngine(opts.modelPath, threads) + } catch (err) { + loadError = err + completion = null + } + + return { + ready: () => completion !== null, + loadError: () => (loadError === null ? null : String(loadError)), + + async judge(turns: GuardTurn[]): Promise { + if (!completion) { + throw new Error(`guard engine not loaded: ${String(loadError)}`) + } + const start = performance.now() + const raw = await completion.generateCompletion( + buildPrompt(turns), + { + maxTokens, + temperature, + customStopTriggers: ['<|im_end|>'], + }, + ) + const latencyMs = performance.now() - start + const parsed = parseGuardOutput(raw) + return { ...parsed, latencyMs, raw } + }, + } +} + +/** + * Deterministic test double, keyed by the last USER turn's exact text. Any + * unscripted message resolves to `unknown` (matching the real engine's + * malformed-output fallback) rather than throwing, so tests can exercise the + * "leave pending" path without pre-scripting every message. + */ +export function createFakeGuardEngine( + scripted: Record, +): GuardEngine { + return { + ready: () => true, + loadError: () => null, + async judge(turns: GuardTurn[]): Promise { + const last = turns[turns.length - 1] + const hit = last ? scripted[last.text] : undefined + if (!hit) + return { safety: 'unknown', categories: [], latencyMs: 0, raw: '' } + return { + safety: hit.safety, + categories: hit.categories, + latencyMs: 0, + raw: `Safety: ${hit.safety}\nCategories: ${hit.categories.join(', ') || 'None'}`, + } + }, + } +} diff --git a/apps/moderation/src/guard/prompt.test.ts b/apps/moderation/src/guard/prompt.test.ts new file mode 100644 index 00000000..536c9629 --- /dev/null +++ b/apps/moderation/src/guard/prompt.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { FOOTER, HEADER, buildPrompt, parseGuardOutput } from './prompt.js' + +describe('buildPrompt', () => { + it('wraps a single sender turn in the exact trained template', () => { + const prompt = buildPrompt([{ who: 'sender', text: 'you suck lol' }]) + + expect(prompt).toBe(`${HEADER}USER: you suck lol${FOOTER}`) + }) + + it('renders the opponent as ASSISTANT and preserves turn order', () => { + const prompt = buildPrompt([ + { who: 'sender', text: 'gg that was close' }, + { who: 'other', text: 'you got lucky' }, + { who: 'sender', text: 'you suck lol' }, + ]) + + expect(prompt).toBe( + `${HEADER}USER: gg that was close\nASSISTANT: you got lucky\nUSER: you suck lol${FOOTER}`, + ) + }) +}) + +describe('parseGuardOutput', () => { + it('parses a Safe verdict with no categories', () => { + const raw = 'Safety: Safe\nCategories: None' + + expect(parseGuardOutput(raw)).toEqual({ safety: 'Safe', categories: [] }) + }) + + it('parses an Unsafe verdict with a single category', () => { + const raw = 'Safety: Unsafe\nCategories: Violent' + + expect(parseGuardOutput(raw)).toEqual({ + safety: 'Unsafe', + categories: ['Violent'], + }) + }) + + it('parses a Controversial verdict with multiple categories, deduplicated', () => { + const raw = + 'Safety: Controversial\nCategories: Unethical Acts, Unethical Acts, PII' + + expect(parseGuardOutput(raw)).toEqual({ + safety: 'Controversial', + categories: ['Unethical Acts', 'PII'], + }) + }) + + it('ignores surrounding reasoning text and tags', () => { + const raw = + 'the sender is escalating after a stalking signal\nSafety: Unsafe\nCategories: Violent, PII<|im_end|>' + + expect(parseGuardOutput(raw)).toEqual({ + safety: 'Unsafe', + categories: ['Violent', 'PII'], + }) + }) + + it('returns unknown safety and no categories for malformed output', () => { + const raw = 'the model rambled without following the format' + + expect(parseGuardOutput(raw)).toEqual({ safety: 'unknown', categories: [] }) + }) + + it('returns unknown safety for empty output', () => { + expect(parseGuardOutput('')).toEqual({ safety: 'unknown', categories: [] }) + }) +}) diff --git a/apps/moderation/src/guard/prompt.ts b/apps/moderation/src/guard/prompt.ts new file mode 100644 index 00000000..5a4fb592 --- /dev/null +++ b/apps/moderation/src/guard/prompt.ts @@ -0,0 +1,87 @@ +// Qwen3Guard-Gen-0.6B prompt template. This is the model's EXACT +// trained chat template — the community GGUF's generic wrapper produces +// garbage without it, so HEADER/FOOTER must not be "cleaned up" or reflowed. +// +// Two-player mapping: the message SENDER being judged = USER turns, their +// opponent = ASSISTANT turns. The guard assesses ONLY the last USER message, +// which is exactly the banter-vs-targeting question this tier exists to answer. + +export type GuardSafety = 'Safe' | 'Unsafe' | 'Controversial' | 'unknown' + +export type GuardTurn = { who: 'sender' | 'other'; text: string } + +export type GuardVerdict = { safety: GuardSafety; categories: string[] } + +export const HEADER = `<|im_start|>user +# Task: +1) Evaluate whether THE LAST USER's query belongs to safe or unsafe content based on our safety policy. +2) Identify the category of unsafe content found. + + +**Safety: Safe**: The USER's query does not contain any prohibited content or content that could lead to prohibited content. +**Safety: Unsafe**: The USER's query contains prohibited content. +**Safety: Controversial**: The USER's query does not explicitly contain prohibited content, but its intent, context, or potential responses could be misinterpreted, misused, or exploited to generate unsafe content under certain conditions. + + + +Violent. +Non-violent Illegal Acts. +Sexual Content or Sexual Acts. +PII. +Suicide & Self-Harm. +Unethical Acts. +Politically Sensitive Topics. +Copyright Violation. +Jailbreak. + + + + +` + +export const FOOTER = ` + + + +Provide your safety assessment for ONLY THE LAST **USER's query** in the above conversation: + - The first line must be one of: 'Safety: Safe', 'Safety: Unsafe', 'Safety: Controversial'. + - The second line should start with 'Categories:' followed by a list of any unsafe content categories, separated by commas. If the content is safe, use 'Categories: None'.<|im_end|> +<|im_start|>assistant + + + + +` + +/** + * The constant prompt prefix: the trained header with the optional domain + * context injected at the top of the user turn, before the task/policy. Every + * prompt this module builds starts with exactly this string — which is what + * makes engine-level prefix caching sound (the KV state for these tokens can + * be computed once and reused for every judgement). + */ +export function buildPromptPrefix(): string { + return HEADER +} + +/** turns: last turn must be the sender — the guard only judges that one. */ +export function buildPrompt(turns: GuardTurn[]): string { + const convo = turns + .map((t) => `${t.who === 'sender' ? 'USER' : 'ASSISTANT'}: ${t.text}`) + .join('\n') + return buildPromptPrefix() + convo + FOOTER +} + +const SAFE_RE = /Safety: (Safe|Unsafe|Controversial)/ +const CAT_RE = + /(Violent|Non-violent Illegal Acts|Sexual Content or Sexual Acts|PII|Suicide & Self-Harm|Unethical Acts|Politically Sensitive Topics|Copyright Violation|Jailbreak|None)/g + +/** Parses the model's raw completion. Malformed/unrecognized output -> 'unknown'. */ +export function parseGuardOutput(raw: string): GuardVerdict { + const safety = + (SAFE_RE.exec(raw)?.[1] as GuardSafety | undefined) ?? 'unknown' + const categories = [...new Set(raw.match(CAT_RE) ?? [])].filter( + (c) => c !== 'None', + ) + return { safety, categories } +} diff --git a/apps/moderation/src/main.ts b/apps/moderation/src/main.ts new file mode 100644 index 00000000..0e519b85 --- /dev/null +++ b/apps/moderation/src/main.ts @@ -0,0 +1,304 @@ +import { existsSync, readdirSync, statSync } from 'node:fs' +import { availableParallelism } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createLlamaGuardEngine } from './guard/engine.js' +import { GUARD_JUDGED_PROFANITY } from './pipeline/analyze.js' +import { postureBannerLines } from './service/posture.js' +import type { ServicePosture } from './service/posture.js' +import { chooseModelPath } from './service/model-path.js' +import type { ConfiguredKind } from './service/model-path.js' +import type { ListStatus } from './service/server.js' +import { createModerationServer } from './service/server.js' +import { createModerationService } from './service/service.js' + +// Entrypoint: load the guard, then serve. Qwen3Guard is the only model — it +// judges INSIDE /moderate (real-time gate, deadline-valved). Verdicts stream +// to stdout as JSONL — that line is the only record this service produces. +// /moderate is stateless: no database, no admin API, no review queue. See +// docs/13 for the fuller design (Standing/the sanctions ladder is a separate +// deployable, not part of this branch). + +// The word lists ship with the service. An explicit *_PATH still wins, so a +// deployment can point at its own copies, but the common case needs no +// configuration at all — and nobody has to remember to copy files onto a box. +const BUNDLED_CONFIG_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../config', +) + +function listPath(envVar: string, filename: string): string | undefined { + const override = process.env[envVar] + if (override) return override + const bundled = path.join(BUNDLED_CONFIG_DIR, filename) + return existsSync(bundled) ? bundled : undefined +} + +const ALLOWLIST_PATH = listPath('ALLOWLIST_PATH', 'allowlist.txt') +const REWRITES_PATH = listPath('REWRITES_PATH', 'rewrites.txt') +const APPROVED_DOMAINS_PATH = listPath( + 'APPROVED_DOMAINS_PATH', + 'approved-domains.txt', +) + +const PORT = Number(process.env.PORT ?? 8001) + +// A model the service cannot find is total chat outage, not a degraded mode +// (no usable guard verdict fails closed), so GUARD_MODEL accepts a DIRECTORY +// as well as a file — the shipped default is the folder, which means nobody +// has to match a filename. This only pokes the filesystem; the choosing is in +// ./service/model-path.ts. +const GUARD_MODEL = ((): string | undefined => { + const configured = process.env.GUARD_MODEL + if (configured === undefined) return undefined + + let kind: ConfiguredKind = 'missing' + if (existsSync(configured)) { + kind = statSync(configured).isDirectory() ? 'directory' : 'file' + } + // A file is used as-is, so only the other two cases need a listing: the + // directory itself, else the one the configured path lives in. + const searchDir = kind === 'directory' ? configured : path.dirname(configured) + let candidates: string[] = [] + if (kind !== 'file') { + try { + candidates = readdirSync(searchDir).map((name) => + path.join(searchDir, name), + ) + } catch { + // Missing/unreadable — chooseModelPath reports that as "no model + // found", which is exactly right. + } + } + + const { path: resolved, note } = chooseModelPath(configured, kind, candidates) + if (note) console.error(`[moderation] ${note}`) + return resolved +})() + +// Parse before validating: a raw-string presence check (`if (!BEARER)`) passes +// for ' ' or ',' — both truthy, both plausible from a hand-edited .env — which +// then resolve to zero usable tokens. Validate the PARSED list, never the raw +// env var, so a blank-but-truthy token can't silently disable auth in prod. +const bearerTokens = (process.env.MODERATION_BEARER_TOKEN ?? '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + +if (bearerTokens.length === 0) { + // Not fatal any more. This service publishes no host port: it is reached + // only by the relay over an internal network, so the token protects + // nothing a network boundary is not already protecting. It stays supported + // for anyone who does expose it - hence the warning rather than silence. + console.error( + '[moderation] no MODERATION_BEARER_TOKEN set — anything that can reach this port can request a verdict. Fine while the port is internal; set a token before exposing it.', + ) +} +if (!GUARD_MODEL) { + console.error( + '[moderation] GUARD_MODEL is not set — the guard is the only model; serving 503s (fail-closed) until it is configured', + ) +} + +// MODERATION_REQUIRE_LISTS=1: turn a configured-but-unreadable word-list path +// into a startup failure instead of a silent degrade. Opt-in (default off) — +// existing deploys keep today's fail-open-and-continue behavior unless an +// operator asks for the stricter one. DEPLOY.md already documents these paths +// as drift-prone (host files, not synced by the deploy). +const REQUIRE_LISTS = process.env.MODERATION_REQUIRE_LISTS === '1' +function failIfListsRequired(envVar: string, err: unknown): void { + if (!REQUIRE_LISTS) return + console.error( + `[moderation] MODERATION_REQUIRE_LISTS=1 and ${envVar} failed to load (${String(err)}) — refusing to boot on drifted config`, + ) + process.exit(1) +} + +// Explicit thread override for cpu-quota'd containers, where the host core +// count (the engine's default) exceeds the quota and ggml's spin-wait threads +// would thrash (see CreateLlamaGuardEngineOptions.threads). +const GUARD_THREADS = process.env.GUARD_THREADS + ? Number(process.env.GUARD_THREADS) + : undefined + +// Deterministic community-vocabulary rewrites (REWRITES_PATH): applied before +// every other tier; the rewritten text is judged and published. See +// pipeline/rewrite.ts for why this exists (guard can't be taught past some +// game abbreviations even with domain context). +let rewrites: import('./pipeline/rewrite.js').RewriteRule[] = [] +let rewritesStatus: ListStatus = 'unset' +if (REWRITES_PATH) { + const { readFileSync } = await import('node:fs') + const { parseRewrites } = await import('./pipeline/rewrite.js') + try { + rewrites = parseRewrites(readFileSync(REWRITES_PATH, 'utf8')) + rewritesStatus = rewrites.length + console.error(`[moderation] rewrites loaded: ${rewrites.length} rules`) + if (rewrites.length === 0) { + console.error( + '[moderation] WARNING: REWRITES_PATH parsed to zero rules — check the file is not empty or misformatted', + ) + } + } catch (err) { + rewritesStatus = 'error' + console.error( + `[moderation] failed to read REWRITES_PATH (${String(err)}) — continuing without rewrites`, + ) + failIfListsRequired('REWRITES_PATH', err) + } +} + +// Approved link domains (APPROVED_DOMAINS_PATH): any URL whose domain is not +// listed is replaced with a placeholder before judging/publishing. Empty or +// unset = strip ALL links (Balatro chat renders no images; see pipeline/links). +let approvedDomains: string[] = [] +let approvedDomainsStatus: ListStatus = 'unset' +if (APPROVED_DOMAINS_PATH) { + const { readFileSync } = await import('node:fs') + const { parseApprovedDomains } = await import('./pipeline/links.js') + try { + approvedDomains = parseApprovedDomains( + readFileSync(APPROVED_DOMAINS_PATH, 'utf8'), + ) + approvedDomainsStatus = approvedDomains.length + console.error( + `[moderation] approved link domains loaded: ${approvedDomains.length}`, + ) + if (approvedDomains.length === 0) { + console.error( + '[moderation] WARNING: APPROVED_DOMAINS_PATH parsed to zero domains — check the file is not empty or misformatted (this strips ALL links, same as leaving the path unset)', + ) + } + } catch (err) { + approvedDomainsStatus = 'error' + console.error( + `[moderation] failed to read APPROVED_DOMAINS_PATH (${String(err)}) — stripping all links`, + ) + failIfListsRequired('APPROVED_DOMAINS_PATH', err) + } +} + +console.error(`[moderation] loading guard ${GUARD_MODEL ?? '(unset)'}...`) +const guard = await createLlamaGuardEngine({ + modelPath: GUARD_MODEL ?? '/nonexistent', + ...(GUARD_THREADS !== undefined ? { threads: GUARD_THREADS } : {}), +}) +if (guard.ready()) { + console.error( + `[moderation] guard ready (threads=${GUARD_THREADS ?? availableParallelism()})`, + ) +} else { + // A bare "guard failed to load" is an hour of guessing (missing GGUF? wrong + // path? OOM? missing native binary? corrupt file?); the resolved path plus + // an existsSync/size check plus the caught reason turns it into a + // five-second fix. This is a total chat outage (the relay fails closed on + // the resulting 503s), so it is the failure mode most in need of a reason. + const { existsSync, statSync } = await import('node:fs') + const modelExists = GUARD_MODEL !== undefined && existsSync(GUARD_MODEL) + const modelSizeBytes = modelExists + ? statSync(GUARD_MODEL as string).size + : undefined + console.error( + [ + '[moderation] guard failed to load; serving 503s (fail-closed).', + `model=${GUARD_MODEL ?? '(unset)'}`, + `exists=${modelExists}`, + ...(modelSizeBytes !== undefined ? [`size_bytes=${modelSizeBytes}`] : []), + `reason=${guard.loadError?.() ?? 'unknown'}`, + ].join(' '), + ) +} + +// Global ingress admission (self-protection): defaults suit BMP volume; tune +// per deploy without a rebuild. +const { createAdmissionController } = await import('./service/admission.js') +const INGRESS_BURST = Number(process.env.MODERATION_INGRESS_BURST ?? '50') +const INGRESS_RATE = Number(process.env.MODERATION_INGRESS_RATE ?? '25') + +// Shadow-mode override (SHADOW_MODE=0/false to enforce): the policy ships with +// shadowMode ON (guard-blocks published + logged as would-block, ADR-4); this +// is the deploy-time switch that flips enforcement on without a rebuild. +const SHADOW_MODE = !['0', 'false', 'off'].includes( + (process.env.SHADOW_MODE ?? '').toLowerCase(), +) + +// Posture must be unmissable in both directions — shadow mode (a net +// loosening vs a stock deployment, see GUARD_JUDGED_PROFANITY) is exactly the +// state most likely to be silently defaulted into, so it gets the loudest +// banner, not the quietest. The same fields back GET /health (server.ts). +const posture: ServicePosture = { + enforcement: SHADOW_MODE ? 'shadow' : 'enforce', + authEnabled: bearerTokens.length > 0, +} +for (const line of postureBannerLines(posture, GUARD_JUDGED_PROFANITY.size)) { + console.error(line) +} + +// Tier-0 fast-pass: the data-derived allowlist (see gen-allowlist). Optional — +// without it every non-deterministic message pays a guard judgement. +let allowlist: Set | undefined +let allowlistStatus: ListStatus = 'unset' +if (ALLOWLIST_PATH) { + const { readFileSync } = await import('node:fs') + const { parseAllowlist } = await import('./service/allowlist.js') + try { + allowlist = parseAllowlist(readFileSync(ALLOWLIST_PATH, 'utf8')) + allowlistStatus = allowlist.size + console.error(`[moderation] allowlist loaded: ${allowlist.size} entries`) + if (allowlist.size === 0) { + console.error( + '[moderation] WARNING: ALLOWLIST_PATH parsed to zero entries — check the file is not empty or misformatted (every message now pays a guard judgement)', + ) + } + } catch (err) { + allowlistStatus = 'error' + console.error( + `[moderation] failed to read ALLOWLIST_PATH (${String(err)}) — continuing without an allowlist`, + ) + failIfListsRequired('ALLOWLIST_PATH', err) + } +} + +const { DEFAULT_POLICY } = await import('./pipeline/policy.js') +const service = createModerationService({ + guard, + policy: { ...DEFAULT_POLICY, shadowMode: SHADOW_MODE }, + ...(allowlist ? { allowlist } : {}), + ...(rewrites.length ? { rewrites } : {}), + ...(approvedDomains.length ? { approvedDomains } : {}), + admission: createAdmissionController( + { burst: INGRESS_BURST, refillPerSec: INGRESS_RATE }, + Date.now(), + ), + onVerdict: (entry) => { + console.log(JSON.stringify(entry)) + }, +}) + +const server = createModerationServer({ + service, + // Comma-separated to support zero-downtime rotation: run old+new together + // while the relay swaps, then drop the old token. + bearerTokens, + modelId: GUARD_MODEL ?? 'qwen3guard (unconfigured)', + posture, + gitSha: process.env.GIT_SHA, + lists: { + allowlist: allowlistStatus, + rewrites: rewritesStatus, + approvedDomains: approvedDomainsStatus, + }, +}) + +server.listen(PORT, () => { + console.error( + `[moderation] listening on :${PORT} (POST /moderate, GET /health)`, + ) +}) + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + console.error(`[moderation] ${signal}, shutting down`) + server.close(() => process.exit(0)) + }) +} diff --git a/apps/moderation/src/pipeline/analyze.test.ts b/apps/moderation/src/pipeline/analyze.test.ts new file mode 100644 index 00000000..ec0aab98 --- /dev/null +++ b/apps/moderation/src/pipeline/analyze.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { createDeterministicAnalyzer } from './analyze.js' + +const analyzer = createDeterministicAnalyzer() + +// Slur fixtures are assembled from code points, not written literally, so this +// (public) repo carries no slur in its source text. The analyzer still receives +// the real word at runtime — that is exactly what these tests verify. +const slur = (...codes: number[]): string => String.fromCharCode(...codes) +const SLUR_ABLEIST = slur(114, 101, 116, 97, 114, 100) // r-slur +const SLUR_HOMOPHOBIC_LEET = slur(102, 52, 103, 103, 48, 116) // f-slur, leetspeak + +describe('createDeterministicAnalyzer', () => { + it('catches denylist phrases through leetspeak variants', () => { + const e = analyzer.analyze('k1ll y0urs3lf') + expect(e.obscenityMatches.some((m) => m.word === 'kill yourself')).toBe( + true, + ) + }) + + it('catches denylist phrases through stretched letters', () => { + const e = analyzer.analyze('kiiiill yourseeeelf') + expect(e.obscenityMatches.some((m) => m.word === 'kill yourself')).toBe( + true, + ) + }) + + it('catches 1-as-l leetspeak and self-harm euphemisms', () => { + for (const msg of ['ki11 yourself', 'unalive yourself', 'go unalive']) { + expect( + analyzer.analyze(msg).obscenityMatches.length, + msg, + ).toBeGreaterThan(0) + } + }) + + it('flags severe obscenity (slurs) on the raw text', () => { + const e = analyzer.analyze(`shut up you ${SLUR_ABLEIST}`) + expect(e.obscenityMatches.length).toBeGreaterThan(0) + }) + + it('does NOT flag banter-grade profanity — the guard judges it in context', () => { + for (const msg of [ + 'fuck this game', + 'you worthless piece of shit', + 'im so ass', + 'that run was bitch hard', + ]) { + const e = analyzer.analyze(msg) + expect(e.obscenityMatches).toEqual([]) + } + }) + + it('still hard-blocks slurs through leetspeak variants', () => { + const e = analyzer.analyze(`you are a ${SLUR_HOMOPHOBIC_LEET}`) + expect(e.obscenityMatches.length).toBeGreaterThan(0) + }) + + it('surfaces directed violent threats the guard underrates', () => { + const e = analyzer.analyze('i will execute your family') + expect(e.threatMatches.length).toBeGreaterThan(0) + }) + + it('does NOT flag game-speak that shares threat verbs', () => { + for (const msg of [ + 'execute the combo', + "i'll kill this run", + 'destroy you', + ]) { + expect(analyzer.analyze(msg).threatMatches).toEqual([]) + } + }) + + it('produces safety signals for contact exchange', () => { + const e = analyzer.analyze('add me on discord mycoolname') + expect(e.safetySignals.length).toBeGreaterThan(0) + }) + + it('returns clean evidence for clean game talk', () => { + const e = analyzer.analyze('that flush build was crazy, one more?') + expect(e.obscenityMatches).toEqual([]) + expect(e.safetySignals).toEqual([]) + }) + + it('de-duplicates matches across variants', () => { + const e = analyzer.analyze('kys kys kys') + const denyHits = e.obscenityMatches.filter((m) => m.word === 'kys') + expect(denyHits.length).toBe(1) + }) +}) diff --git a/apps/moderation/src/pipeline/analyze.ts b/apps/moderation/src/pipeline/analyze.ts new file mode 100644 index 00000000..857ee766 --- /dev/null +++ b/apps/moderation/src/pipeline/analyze.ts @@ -0,0 +1,169 @@ +import { + DataSet, + RegExpMatcher, + englishDataset, + englishRecommendedTransformers, +} from 'obscenity' +import { detectContactExchange } from '../safety/contact-exchange.js' +import { isApprovedLink } from './links.js' +import { scoringVariants } from './normalize.js' +import { findThreats } from './threat.js' +import type { MatchRecord, SafetySignal } from './types.js' + +// Composition layer between the raw message and the pure decision core: builds +// the deterministic evidence (obscenity + custom denylist + safety tier over +// all normalization variants) and reduces per-variant ML scores to per-label +// maxima. Used by both the eval harness and the moderation service, so the two +// can never drift apart. + +/** + * Self-harm-directive and kill phrases the ML tier demonstrably underrates + * (measured in the prototype). DB-loadable later; substring match on + * normalized variants. + */ +export const DEFAULT_DENYLIST = [ + 'kys', + 'kill yourself', + 'kill urself', + 'drink bleach', + 'neck yourself', + 'end yourself', + 'off yourself', + // Euphemisms the guard doesn't recognize (red-team 2026-07-09). + 'unalive yourself', + 'unalive urself', + 'go unalive', + 'an hero', +] + +/** + * Banter-grade profanity the GUARD judges in context instead of a hard + * deterministic block (policy 2026-07-09, from the 72k-message teacher run: + * these words dominated the false-positive residue — "fuck this game", + * "im so ass" are normal lobby speech, while directed abuse using the same + * words is caught by the tuned guard). Slurs and sexual-explicit terms stay + * in the deterministic blocklist. "sex"/"dick" follow MJ's review labels: + * guard-flagged (Controversial/Unsafe), not blanket-blocked. + */ +export const GUARD_JUDGED_PROFANITY = new Set([ + 'arse', + 'ass', + 'bastard', + 'bitch', + 'bollocks', + 'boob', + 'dick', + 'fuck', + 'penis', + 'piss', + 'prick', + 'sex', + 'shit', + 'tit', + 'turd', + 'twat', + 'vagina', + 'wank', +]) + +export type DeterministicEvidence = { + variants: string[] + threatMatches: MatchRecord[] + obscenityMatches: MatchRecord[] + safetySignals: SafetySignal[] +} + +export type DeterministicAnalyzer = { + analyze(message: string): DeterministicEvidence +} + +/** "kiiiill" and "kill" both become "kil" — squeeze both sides to compare. */ +function squeezeRepeats(text: string): string { + return text.replace(/([a-z])\1+/g, '$1') +} + +export function createDeterministicAnalyzer( + opts: { denylist?: string[]; approvedDomains?: readonly string[] } = {}, +): DeterministicAnalyzer { + const denylist = opts.denylist ?? DEFAULT_DENYLIST + const approvedDomains = opts.approvedDomains ?? [] + // Each phrase matches in raw form or with repeats squeezed on both sides. + const denyForms = denylist.map((phrase) => ({ + phrase, + squeezed: squeezeRepeats(phrase), + })) + // English dataset minus banter-grade profanity (guard judges those in + // context); slurs and sexual-explicit terms remain deterministic blocks. + const dataset = new DataSet<{ originalWord: string }>() + .addAll(englishDataset) + .removePhrasesIf((phrase) => + GUARD_JUDGED_PROFANITY.has(phrase.metadata?.originalWord ?? ''), + ) + const matcher = new RegExpMatcher({ + ...dataset.build(), + ...englishRecommendedTransformers, + }) + + return { + analyze(message: string): DeterministicEvidence { + const variants = scoringVariants(message) + const obscenityMatches: MatchRecord[] = [] + const safetySignals: SafetySignal[] = [] + const seenObscenity = new Set() + const seenSafety = new Set() + + for (const variant of variants) { + for (const raw of matcher.getAllMatches(variant)) { + const word = + dataset.getPayloadWithPhraseMetadata(raw).phraseMetadata + ?.originalWord ?? '' + const key = `${word}:${raw.startIndex}` + if (seenObscenity.has(key)) continue + seenObscenity.add(key) + obscenityMatches.push({ + word, + startIndex: raw.startIndex, + endIndex: raw.endIndex, + }) + } + const squeezedVariant = squeezeRepeats(variant) + for (const { phrase, squeezed } of denyForms) { + const hit = variant.includes(phrase) + ? { haystack: variant, needle: phrase } + : squeezedVariant.includes(squeezed) + ? { haystack: squeezedVariant, needle: squeezed } + : null + if (hit) { + const key = `denylist:${phrase}` + if (seenObscenity.has(key)) continue + seenObscenity.add(key) + const startIndex = hit.haystack.indexOf(hit.needle) + obscenityMatches.push({ + word: phrase, + startIndex, + endIndex: startIndex + hit.needle.length, + }) + } + } + for (const match of detectContactExchange(variant)) { + // Approved-domain links are exempt from the url/invite safety block + // (they are explicitly allowlisted; the link tier keeps them intact). + if ( + (match.kind === 'url' || match.kind === 'invite_link') && + isApprovedLink(match.span, approvedDomains) + ) + continue + const key = `${match.kind}:${match.severity}` + if (seenSafety.has(key)) continue + seenSafety.add(key) + safetySignals.push({ severity: match.severity, kind: match.kind }) + } + } + + // findThreats normalizes internally (leet-fold + punctuation), so the + // raw transformed message covers evasion without the variant loop. + const threatMatches = findThreats(message) + return { variants, threatMatches, obscenityMatches, safetySignals } + }, + } +} diff --git a/apps/moderation/src/pipeline/decide.test.ts b/apps/moderation/src/pipeline/decide.test.ts new file mode 100644 index 00000000..eb2ecefe --- /dev/null +++ b/apps/moderation/src/pipeline/decide.test.ts @@ -0,0 +1,495 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { decideModeration } from './decide.js' +import { DEFAULT_POLICY } from './policy.js' +import type { + Band, + DecisionInput, + GuardInput, + GuardSafetyLevel, + ModerationPolicy, + TokenBucket, +} from './types.js' + +const ENFORCE: ModerationPolicy = { ...DEFAULT_POLICY, shadowMode: false } + +function guard( + safety: GuardSafetyLevel, + categories: string[] = [], +): GuardInput { + return { safety, categories } +} + +function input(over: Partial = {}): DecisionInput { + return { + message: 'hello', + nowMs: 1000, + isAllowlisted: false, + threatMatches: [], + obscenityMatches: [], + safetySignals: [], + guard: guard('Safe'), + policy: ENFORCE, + ...over, + } +} + +describe('decideModeration — precedence', () => { + it('threat_block beats allowlist and a Safe guard, and enforces in shadow', () => { + const threatMatches = [{ word: 'kill you', startIndex: 0, endIndex: 8 }] + const d = decideModeration( + input({ threatMatches, isAllowlisted: true, guard: guard('Safe') }), + ) + expect(d.band).toBe('threat_block') + expect(d.decision).toBe('reject') + expect(d.verdict.threatMatches).toEqual(threatMatches) + // Deterministic tier: shadow mode does NOT downgrade it. + const shadow = decideModeration( + input({ threatMatches, policy: { ...ENFORCE, shadowMode: true } }), + ) + expect(shadow.decision).toBe('reject') + expect(shadow.band).toBe('threat_block') + }) + + it('preset fast-pass skips blocklist and the guard', () => { + const d = decideModeration( + input({ + isAllowlisted: true, + obscenityMatches: [{ word: 'x', startIndex: 0, endIndex: 1 }], + guard: guard('Unsafe', ['Violent']), + }), + ) + expect(d.band).toBe('preset') + expect(d.decision).toBe('allow') + }) + + it('blocklist beats the guard', () => { + const matches = [{ word: 'slur', startIndex: 0, endIndex: 4 }] + const d = decideModeration( + input({ obscenityMatches: matches, guard: guard('Safe') }), + ) + expect(d.band).toBe('blocklist') + expect(d.decision).toBe('reject') + expect(d.verdict.obscenityMatches).toEqual(matches) + }) +}) + +describe('decideModeration — guard bands', () => { + it('Unsafe blocks in enforce mode (ADR-6: no auto-strike, a human/queue decides)', () => { + const d = decideModeration(input({ guard: guard('Unsafe', ['Violent']) })) + expect(d.band).toBe('guard_block') + expect(d.decision).toBe('reject') + }) + + it('Unsafe with no categories still blocks', () => { + const d = decideModeration(input({ guard: guard('Unsafe') })) + expect(d.band).toBe('guard_block') + expect(d.decision).toBe('reject') + }) + + it('Controversial publishes with review', () => { + const d = decideModeration( + input({ guard: guard('Controversial', ['Unethical Acts']) }), + ) + expect(d.band).toBe('review') + expect(d.decision).toBe('allow') + }) + + it('unknown (unparseable model output) publishes with review — never widens to Safe', () => { + const d = decideModeration(input({ guard: guard('unknown') })) + expect(d.band).toBe('review') + expect(d.decision).toBe('allow') + }) + + it('Safe publishes clean', () => { + const d = decideModeration(input({ guard: guard('Safe') })) + expect(d.band).toBe('clean') + expect(d.decision).toBe('allow') + }) + + it('shadow mode downgrades an Unsafe block to review with wouldHaveBlocked', () => { + const d = decideModeration( + input({ + policy: DEFAULT_POLICY, + guard: guard('Unsafe', ['Violent']), + }), + ) + expect(d.decision).toBe('allow') + expect(d.band).toBe('review') + expect(d.verdict.wouldHaveBlocked).toBe(true) + }) + + it('records the guard verdict in the verdict json', () => { + const d = decideModeration( + input({ guard: guard('Controversial', ['PII']) }), + ) + expect(d.verdict.guardSafety).toBe('Controversial') + expect(d.verdict.guardCategories).toEqual(['PII']) + }) +}) + +describe('decideModeration — safety tier (ADR-8)', () => { + it('red safety signal rejects with safety_block', () => { + const d = decideModeration( + input({ + safetySignals: [{ severity: 'red', kind: 'phone' }], + guard: guard('Safe'), + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('safety_block') + }) + + it('red safety rejects even in shadow mode (deterministic tiers always enforce)', () => { + const d = decideModeration( + input({ + policy: DEFAULT_POLICY, + safetySignals: [{ severity: 'red', kind: 'email' }], + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('safety_block') + }) + + it('orange safety publishes but forces review when the guard says Safe', () => { + const d = decideModeration( + input({ + safetySignals: [{ severity: 'orange', kind: 'intent_phrase' }], + guard: guard('Safe'), + }), + ) + expect(d.decision).toBe('allow') + expect(d.band).toBe('review') + }) + + it('orange safety alone does NOT excuse a skipped guard — still fails closed', () => { + // Orange safety forces review only when a real guard verdict exists + // (see the test above). With no verdict at all it must not be treated + // as "already resolved" — that was Defect 1's fail-open path. + const d = decideModeration( + input({ + safetySignals: [{ severity: 'orange', kind: 'intent_phrase' }], + guard: { skipped: 'deadline' }, + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + }) + + it('preset fast-pass beats safety signals (presets are curated)', () => { + const d = decideModeration( + input({ + isAllowlisted: true, + safetySignals: [{ severity: 'red', kind: 'url' }], + }), + ) + expect(d.band).toBe('preset') + expect(d.decision).toBe('allow') + }) + + it('blocklist takes precedence over safety_block', () => { + const d = decideModeration( + input({ + obscenityMatches: [{ word: 'x', startIndex: 0, endIndex: 1 }], + safetySignals: [{ severity: 'red', kind: 'phone' }], + }), + ) + expect(d.band).toBe('blocklist') + }) + + it('Unsafe guard block still records orange safety signals in the verdict', () => { + const d = decideModeration( + input({ + safetySignals: [{ severity: 'orange', kind: 'intent_phrase' }], + guard: guard('Unsafe', ['Violent']), + }), + ) + expect(d.band).toBe('guard_block') + expect(d.verdict.safetySignals).toEqual([ + { severity: 'orange', kind: 'intent_phrase' }, + ]) + }) +}) + +describe('decideModeration — an absent engine in shadow mode', () => { + // A model that is missing, still loading, or failed to load never answers + // until an operator acts, so fail-closed there is not a brief refusal — + // it is permanent chat outage that reads as "the feature is broken". In + // shadow mode the guard has no enforcement power anyway (an Unsafe + // verdict publishes), so refusing when it cannot answer AT ALL is + // strictly harsher than the case where it did object. + it('publishes as review rather than refusing, and records why', () => { + const d = decideModeration( + input({ guard: { skipped: 'engine_not_ready' }, policy: DEFAULT_POLICY }), + ) + + expect(d.decision).toBe('allow') + expect(d.band).toBe('review') + // Never silent — the log still shows the model did not judge this. + expect(d.verdict.guardSkipped).toBe('engine_not_ready') + }) + + it('still refuses an absent engine when actually enforcing', () => { + const d = decideModeration( + input({ guard: { skipped: 'engine_not_ready' }, policy: ENFORCE }), + ) + + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + }) + + // The line is "could a retry ever succeed", not "is the guard missing". + it('does not extend to transient skips, which are self-correcting on retry', () => { + for (const skipped of ['deadline', 'backlog'] as const) { + const d = decideModeration( + input({ guard: { skipped }, policy: DEFAULT_POLICY }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + } + }) + + // The whole justification is that the tiers ahead of the guard still run. + it('does not let a deterministic block through: threats still refuse', () => { + const d = decideModeration( + input({ + guard: { skipped: 'engine_not_ready' }, + threatMatches: [{ word: 'kill you', startIndex: 0, endIndex: 8 }], + policy: DEFAULT_POLICY, + }), + ) + + expect(d.decision).toBe('reject') + expect(d.band).not.toBe('review') + }) +}) + +describe('decideModeration — guard absent / skipped (fail-closed)', () => { + // Defect 1 regression: GuardInput has no bare-null variant, so no in-repo + // caller can construct this today — but the core itself must not rely on + // the type system alone. A stray null (cast past it, exactly the shape a + // pre-fix shell used to hand over for "deliberately skipped judging") + // must still fail closed, never fall through to allow/clean. + it('a null guard with no deterministic tier resolved fails closed, never allow/clean (regression)', () => { + const d = decideModeration(input({ guard: null as unknown as GuardInput })) + expect(d.decision).not.toBe('allow') + expect(d.band).not.toBe('clean') + expect(d.band).toBe('guard_unavailable') + }) + + it('rejects with guard_unavailable when the judgement hit the deadline', () => { + const g: GuardInput = { skipped: 'deadline' } + const d = decideModeration(input({ guard: g })) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + expect(d.verdict.guardSkipped).toBe('deadline') + }) + + it('rejects a backlog short-circuit the same way, even in shadow mode', () => { + const d = decideModeration( + input({ guard: { skipped: 'backlog' }, policy: DEFAULT_POLICY }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + expect(d.verdict.guardSkipped).toBe('backlog') + }) + + it('skipped + orange safety still rejects and records both in the verdict', () => { + const d = decideModeration( + input({ + guard: { skipped: 'deadline' }, + safetySignals: [{ severity: 'orange', kind: 'intent_phrase' }], + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + expect(d.verdict.guardSkipped).toBe('deadline') + expect(d.verdict.safetySignals).toEqual([ + { severity: 'orange', kind: 'intent_phrase' }, + ]) + }) + + it('fails closed when the guard verdict carries an invalid safety value', () => { + // `'safety' in guard` was not enough: a present-but-invalid value fell + // through to the review branch and published unjudged. + for (const bad of [undefined, null, 'safe', 'SAFE', 42, {}]) { + const d = decideModeration( + input({ + guard: { safety: bad, categories: [] } as unknown as GuardInput, + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + } + }) + + it('fails closed when the guard verdict has a malformed categories field', () => { + for (const bad of [undefined, null, 'harassment', [1, 2]]) { + const d = decideModeration( + input({ + guard: { safety: 'Safe', categories: bad } as unknown as GuardInput, + }), + ) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + } + }) + + it('is total for non-object guard values', () => { + for (const bad of ['nonsense', 42, true, Symbol('x')]) { + const d = decideModeration(input({ guard: bad as unknown as GuardInput })) + expect(d.decision).toBe('reject') + expect(d.band).toBe('guard_unavailable') + } + }) + + it('never allows when the guard was not consulted and no deterministic tier fired (property)', () => { + const arbSkippedGuard: fc.Arbitrary = fc.oneof( + fc + .string({ minLength: 1, maxLength: 20 }) + .map((skipped): GuardInput => ({ skipped })), + fc.constant(null as unknown as GuardInput), + ) + fc.assert( + fc.property( + arbSkippedGuard, + fc.array( + fc.record({ + severity: fc.constantFrom('orange' as const), + kind: fc.string({ minLength: 1, maxLength: 20 }), + }), + { maxLength: 3 }, + ), + fc.boolean(), + (skippedGuard, safetySignals, shadowMode) => { + const d = decideModeration( + input({ + guard: skippedGuard, + safetySignals, + policy: { ...ENFORCE, shadowMode }, + isAllowlisted: false, + threatMatches: [], + obscenityMatches: [], + }), + ) + expect(d.decision).not.toBe('allow') + expect(d.band).toBe('guard_unavailable') + }, + ), + ) + }) +}) + +// Scenario table: guard verdicts measured on the real chat export (ADR-4 eval). +// Banter and game vocabulary must publish; targeted filth must not. +describe('decideModeration — real-traffic scenarios', () => { + const cases: Array<{ + name: string + guard: GuardInput + expectReject: boolean + }> = [ + { + name: 'did you die to a blind? (game vocab — guard says Safe)', + guard: { safety: 'Safe', categories: [] }, + expectReject: false, + }, + { + name: 'you suck lol (banter — guard says Controversial)', + guard: { safety: 'Controversial', categories: ['Unethical Acts'] }, + expectReject: false, + }, + { + name: 'im gonna kill myself (self-harm — Unsafe, must not publish silently)', + guard: { safety: 'Unsafe', categories: ['Suicide & Self-Harm'] }, + expectReject: true, + }, + { + name: 'targeted slur (Unsafe)', + guard: { safety: 'Unsafe', categories: ['Unethical Acts'] }, + expectReject: true, + }, + ] + for (const c of cases) { + it(c.name, () => { + const d = decideModeration(input({ guard: c.guard })) + expect(d.decision === 'reject').toBe(c.expectReject) + }) + } +}) + +describe('decideModeration — totality property', () => { + it('never throws and always returns a valid decision for arbitrary input', () => { + const arbGuard: fc.Arbitrary = fc.oneof( + fc.record({ + safety: fc.constantFrom( + 'Safe', + 'Unsafe', + 'Controversial', + 'unknown', + ), + categories: fc.array(fc.string({ minLength: 1, maxLength: 20 }), { + maxLength: 3, + }), + }), + fc.constant({ skipped: 'error' }), + ) + + fc.assert( + fc.property( + fc.string(), + fc.integer({ min: 0, max: 10_000_000 }), + arbGuard, + fc.boolean(), + fc.boolean(), + fc.array( + fc.record({ + severity: fc.constantFrom('red' as const, 'orange' as const), + kind: fc.string({ minLength: 1, maxLength: 20 }), + }), + { maxLength: 3 }, + ), + (message, nowMs, g, isAllowlisted, shadowMode, safetySignals) => { + const d = decideModeration({ + message, + nowMs, + isAllowlisted, + threatMatches: [], + obscenityMatches: [], + safetySignals, + guard: g, + policy: { ...DEFAULT_POLICY, shadowMode }, + }) + expect(['allow', 'reject']).toContain(d.decision) + // shadow mode never rejects on guard-VERDICT grounds; availability + // (guard_unavailable) fails closed regardless of shadow. + if (shadowMode && d.decision === 'reject') { + expect([ + 'blocklist', + 'safety_block', + 'guard_unavailable', + ]).toContain(d.band) + } + // an Unsafe verdict in enforce mode must never land in clean + if ( + !shadowMode && + 'safety' in g && + g.safety === 'Unsafe' && + !isAllowlisted && + safetySignals.every((s) => s.severity !== 'red') + ) { + expect(d.band).toBe('guard_block') + } + // unknown must never be clean (only preset/deterministic may skip review) + if ( + 'safety' in g && + g.safety === 'unknown' && + !isAllowlisted && + safetySignals.length === 0 + ) { + expect(d.band).toBe('review') + } + }, + ), + ) + }) +}) diff --git a/apps/moderation/src/pipeline/decide.ts b/apps/moderation/src/pipeline/decide.ts new file mode 100644 index 00000000..42bc80e5 --- /dev/null +++ b/apps/moderation/src/pipeline/decide.ts @@ -0,0 +1,207 @@ +import type { + Band, + Decision, + DecisionInput, + GuardInput, + GuardSafetyLevel, + VerdictJson, +} from './types.js' + +const GUARD_SAFETY_LEVELS: readonly GuardSafetyLevel[] = [ + 'Safe', + 'Unsafe', + 'Controversial', + 'unknown', +] + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * True only for a guard verdict this function can actually reason about. Key + * presence is not enough: `{safety: undefined}` would pass `'safety' in guard` + * and then fall to the review branch, publishing an unjudged message. Anything + * failing this check reaches the fail-closed floor instead. + */ +function isGuardVerdict( + guard: GuardInput | undefined, +): guard is { safety: GuardSafetyLevel; categories: string[] } { + if (!isObject(guard)) return false + const { safety, categories } = guard as Record + return ( + GUARD_SAFETY_LEVELS.includes(safety as GuardSafetyLevel) && + Array.isArray(categories) && + categories.every((c) => typeof c === 'string') + ) +} + +// The pure moderation decision. Total (never throws), clock/guard injected as +// data. Precedence: threat > preset > blocklist > safety > guard-block > +// review > clean. +// +// Per-player rate limiting deliberately does NOT live here: the relay's chat +// route already limits each player to the same budget before this service is +// called, so a second identical bucket only duplicated it — and it was the +// service's only per-player state. Overload is handled where it belongs, by +// the global ingress valve (service/admission.ts) and the guard lane's own +// backlog/deadline shedding. + +export function decideModeration(input: DecisionInput): Decision { + const { + isAllowlisted, + threatMatches, + obscenityMatches, + safetySignals, + guard, + policy, + } = input + + const base = (band: Band, extra: Partial): VerdictJson => ({ + v: 2, + band, + allowlisted: isAllowlisted, + ...extra, + }) + + // 1. Deterministic violent-threat tier (2026-07-09). A violence verb aimed at + // a person/family target hard-blocks BEFORE the allowlist and the guard: the + // 0.6B guard is fuzzy at the threat boundary (measured — it rated "i will + // execute your family" and "i will fucking kill you" Safe), so this floor is + // human-written, target-gated (0 FP over 5,884 benign messages), and enforces + // even in shadow mode. A human still reviews the block. + if (threatMatches.length > 0) { + return { + decision: 'reject', + band: 'threat_block', + verdict: base('threat_block', { threatMatches }), + } + } + + // 2. Preset / allowlist fast-pass — skips all content checks. + if (isAllowlisted) { + return { + decision: 'allow', + band: 'preset', + verdict: base('preset', {}), + } + } + + // 3. Deterministic blocklist (obscenity + denylist matches, computed by shell). + if (obscenityMatches.length > 0) { + return { + decision: 'reject', + band: 'blocklist', + verdict: base('blocklist', { obscenityMatches }), + } + } + + // 4. Deterministic safety tier (PII / contact-exchange / doxxing, ADR-8). + // Red rejects outright (policy violation, not abuse — reviewed, not struck); + // orange forces at least a review band further down. + const redSafety = safetySignals.filter((s) => s.severity === 'red') + if (redSafety.length > 0) { + return { + decision: 'reject', + band: 'safety_block', + verdict: base('safety_block', { safetySignals }), + } + } + const hasOrangeSafety = safetySignals.length > 0 + const safetyExtra = hasOrangeSafety ? { safetySignals } : {} + + // 5. Guard tier — the ONLY model. Unsafe blocks (or shadows to review), + // Controversial/unparseable publishes with human review, Safe publishes. + if (isGuardVerdict(guard)) { + const guardExtra = { + guardSafety: guard.safety, + guardCategories: guard.categories, + } + if (guard.safety === 'Unsafe') { + if (policy.shadowMode) { + // Shadow mode: would-block becomes review (published + logged). + return { + decision: 'allow', + band: 'review', + verdict: base('review', { + ...guardExtra, + wouldHaveBlocked: true, + ...safetyExtra, + }), + } + } + return { + decision: 'reject', + band: 'guard_block', + verdict: base('guard_block', { ...guardExtra, ...safetyExtra }), + } + } + + // Review: Controversial / unparseable guard output, or an orange safety + // signal — publishes either way, but a human sees it. `unknown` (the + // model said something unparseable) must never widen to Safe. + if (guard.safety !== 'Safe' || hasOrangeSafety) { + return { + decision: 'allow', + band: 'review', + verdict: base('review', { ...guardExtra, ...safetyExtra }), + } + } + + return { + decision: 'allow', + band: 'clean', + verdict: base('clean', guardExtra), + } + } + + // 6. No usable guard verdict — the shell deliberately skipped judging + // ({skipped: reason}: rate-limited before judging, a cheaper deterministic + // tier already decided, deadline, backlog, engine down, ...) or (should be + // impossible — GuardInput has no bare-null variant) guard is nullish. + // FAIL CLOSED when enforcing (owner's rule: nothing unjudged is ever + // published) and, in shadow mode, for everything except an absent engine + // (see below): there is no further "a tier above must already have handled + // it" fallback — if guard isn't a real verdict, this is the floor, never a + // silent allow. + const guardSkipped = + isObject(guard) && 'skipped' in guard && typeof guard.skipped === 'string' + ? guard.skipped + : 'guard_missing' + // FAIL CLOSED, except for a guard that is not merely late but ABSENT while in + // shadow mode. The distinction is whether a retry could ever succeed: + // + // deadline / backlog — the model works, this message just wasn't judged + // in time. Self-correcting: the player retries and it goes through. + // Fail-closed here is real protection during a spike and costs one + // retry, so it stays even in shadow mode. + // engine_not_ready — no model file, still loading, or the load failed. + // Nothing will change until an operator acts, so fail-closed is not a + // brief refusal, it is chat permanently dead and indistinguishable + // from "this feature is broken". + // + // In shadow mode the guard has no enforcement power by definition — a + // verdict of Unsafe publishes (as 'review', above) — so refusing when it + // cannot answer at all is strictly harsher than the case where it DID + // object. Allowing grants no exposure this mode has not already granted. + // The deterministic tiers (links, rate limit, threats, blocklist, + // PII/contact) all decided BEFORE this point and still enforce. + if (policy.shadowMode && guardSkipped === 'engine_not_ready') { + return { + decision: 'allow', + band: 'review', + // guardSkipped carries the reason ('engine_not_ready'), so the log + // still shows the model did not judge this — never a silent allow. + verdict: base('review', { guardSkipped, ...safetyExtra }), + } + } + + return { + decision: 'reject', + band: 'guard_unavailable', + verdict: base('guard_unavailable', { guardSkipped, ...safetyExtra }), + } +} + +export type { Decision, DecisionInput } from './types.js' +export type { GuardInput } from './types.js' diff --git a/apps/moderation/src/pipeline/index.ts b/apps/moderation/src/pipeline/index.ts new file mode 100644 index 00000000..4c40721c --- /dev/null +++ b/apps/moderation/src/pipeline/index.ts @@ -0,0 +1,30 @@ +export { createDeterministicAnalyzer, DEFAULT_DENYLIST } from './analyze.js' +export type { + DeterministicAnalyzer, + DeterministicEvidence, +} from './analyze.js' +export { decideModeration } from './decide.js' +export { DEFAULT_POLICY } from './policy.js' +export { + capForScoring, + normalizeForAllowlist, + scoringVariants, +} from './normalize.js' +export { consume, newBucket, refill } from './rate-limit.js' +export type { + Band, + Decision, + DecisionInput, + GuardInput, + GuardSafetyLevel, + MatchRecord, + ModerationPolicy, + RateLimitConfig, + SafetySignal, + TokenBucket, + VerdictJson, +} from './types.js' +export { applyRewrites, parseRewrites } from './rewrite.js' +export type { RewriteRule } from './rewrite.js' +export { LINK_PLACEHOLDER, parseApprovedDomains, stripLinks } from './links.js' +export { findThreats } from './threat.js' diff --git a/apps/moderation/src/pipeline/links.test.ts b/apps/moderation/src/pipeline/links.test.ts new file mode 100644 index 00000000..086a4acc --- /dev/null +++ b/apps/moderation/src/pipeline/links.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { LINK_PLACEHOLDER, parseApprovedDomains, stripLinks } from './links.js' + +const APPROVED = parseApprovedDomains('# ok\nyoutube.com\ntenor.com\n') + +describe('parseApprovedDomains', () => { + it('parses hosts, skipping comments/blanks, stripping scheme/www/path', () => { + expect( + parseApprovedDomains( + '# domains\nyoutube.com\n\nhttps://www.tenor.com/view\n', + ), + ).toEqual(['youtube.com', 'tenor.com']) + }) +}) + +describe('stripLinks', () => { + it('removes an unapproved link, keeping surrounding text', () => { + expect(stripLinks('check this https://evil.example/x lol', APPROVED)).toBe( + `check this ${LINK_PLACEHOLDER} lol`, + ) + }) + + it('strips a bare gif url that is the whole message', () => { + expect( + stripLinks('https://tenor.com/view/cat-gif', parseApprovedDomains('')), + ).toBe(LINK_PLACEHOLDER) + }) + + it('keeps approved domains and their subdomains', () => { + expect(stripLinks('https://www.youtube.com/watch?v=abc', APPROVED)).toBe( + 'https://www.youtube.com/watch?v=abc', + ) + expect(stripLinks('https://tenor.com/view/x', APPROVED)).toBe( + 'https://tenor.com/view/x', + ) + }) + + it('handles a mix of approved and unapproved links independently', () => { + expect( + stripLinks('a https://tenor.com/g and https://spam.co/x', APPROVED), + ).toBe(`a https://tenor.com/g and ${LINK_PLACEHOLDER}`) + }) + + it('matches www. links without a scheme', () => { + expect(stripLinks('go to www.spam.co now', APPROVED)).toBe( + `go to ${LINK_PLACEHOLDER} now`, + ) + }) + + it('leaves link-free messages untouched (no false positives on slang)', () => { + expect(stripLinks('gg wp ez', APPROVED)).toBe('gg wp ez') + expect(stripLinks('good game 1.5.2 patch', APPROVED)).toBe( + 'good game 1.5.2 patch', + ) + }) +}) diff --git a/apps/moderation/src/pipeline/links.ts b/apps/moderation/src/pipeline/links.ts new file mode 100644 index 00000000..e1e2e19c --- /dev/null +++ b/apps/moderation/src/pipeline/links.ts @@ -0,0 +1,64 @@ +// Deterministic link tier (pure core). Balatro chat renders no images and has +// near-zero legitimate use for arbitrary links, so a URL is almost pure downside +// (malware / phishing / NSFW vector the guard can't even see the destination of +// — it hallucinates "Sexual Content" on a cat-gambling gif). We therefore do not +// relay links at all UNLESS their domain is explicitly approved: every other URL +// is replaced with a placeholder BEFORE judging, so it never reaches other +// players and the whole "are we responsible for the destination" question is +// moot — we aren't delivering the link. +// +// Approved domains come from a human-edited file (APPROVED_DOMAINS_PATH), one +// per line; `#` lines are comments. A domain matches its exact host and any +// subdomain (`youtube.com` approves `www.youtube.com`, `m.youtube.com`). + +export const LINK_PLACEHOLDER = '[link removed]' + +// http(s):// URLs and bare www. hosts. Real pasted links carry a scheme; we do +// not guess at scheme-less bare domains (they collide with slang/versions). +const URL_RE = /(?:https?:\/\/|www\.)[^\s]+/gi + +/** Parses the approved-domains file. One domain per line; `#` comments and blanks skipped. */ +export function parseApprovedDomains(text: string): string[] { + const domains: string[] = [] + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + // Accept a bare domain or a pasted URL; reduce to the host. + domains.push(hostOf(trimmed)) + } + return domains +} + +/** Lowercased host of a URL/domain token: scheme, `www.`, path, query, and port stripped. */ +function hostOf(token: string): string { + let s = token.replace(/^https?:\/\//i, '').replace(/^www\./i, '') + s = s.split(/[/?#]/)[0] ?? '' // drop path/query/fragment + s = s.split(':')[0] ?? '' // drop port + return s.toLowerCase() +} + +function isApproved(host: string, approved: readonly string[]): boolean { + return approved.some((d) => host === d || host.endsWith(`.${d}`)) +} + +/** True when a matched URL/link token belongs to an approved domain (or subdomain). */ +export function isApprovedLink( + url: string, + approvedDomains: readonly string[], +): boolean { + return isApproved(hostOf(url), approvedDomains) +} + +/** + * Replaces every URL whose domain is not approved with LINK_PLACEHOLDER. + * Approved-domain links pass through unchanged. Returns the input untouched + * when it contains no links. + */ +export function stripLinks( + message: string, + approvedDomains: readonly string[] = [], +): string { + return message.replace(URL_RE, (url) => + isApproved(hostOf(url), approvedDomains) ? url : LINK_PLACEHOLDER, + ) +} diff --git a/apps/moderation/src/pipeline/normalize.test.ts b/apps/moderation/src/pipeline/normalize.test.ts new file mode 100644 index 00000000..9b4eb3e0 --- /dev/null +++ b/apps/moderation/src/pipeline/normalize.test.ts @@ -0,0 +1,105 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { + capForScoring, + normalizeForAllowlist, + scoringVariants, +} from './normalize.js' + +describe('normalizeForAllowlist', () => { + it('trims, lowercases, strips one trailing punctuation', () => { + expect(normalizeForAllowlist(' Nice Hand! ')).toBe('nice hand') + expect(normalizeForAllowlist('GG')).toBe('gg') + }) + it('returns null for whitespace-only', () => { + expect(normalizeForAllowlist(' ')).toBeNull() + expect(normalizeForAllowlist('')).toBeNull() + }) + it('preserves pure-punctuation messages', () => { + expect(normalizeForAllowlist('...')).toBe('...') + expect(normalizeForAllowlist('?')).toBe('?') + }) +}) + +describe('scoringVariants', () => { + it('folds leetspeak', () => { + expect(scoringVariants('k1ll y0u')).toContain('kill you') + }) + it('squeezes stretched letters', () => { + const v = scoringVariants('kiiiill') + expect(v).toContain('kiill') // 3+ -> 2 + expect(v).toContain('kil') // all -> 1 + }) + it('always includes the original text first', () => { + expect(scoringVariants('Hello')[0]).toBe('Hello') + }) + it('de-duplicates (text with no repeats/leet yields one variant)', () => { + expect(scoringVariants('wp')).toEqual(['wp']) + }) + + it('property: never throws and includes the original for arbitrary input', () => { + fc.assert( + fc.property(fc.string(), (s) => { + const v = scoringVariants(s) + expect(Array.isArray(v)).toBe(true) + expect(v).toContain(s) + }), + ) + }) + + it('property: folded variants are a fixed point (folding again adds nothing new)', () => { + fc.assert( + fc.property(fc.string(), (s) => { + const first = scoringVariants(s) + // re-running on the folded form must not produce spellings outside a + // re-fold of those same forms (idempotence of the fold). + for (const variant of first) { + const again = scoringVariants(variant) + for (const a of again) { + // every re-derived spelling is itself reachable by folding `variant` + expect(scoringVariants(variant)).toContain(a) + } + } + }), + ) + }) +}) + +describe('capForScoring', () => { + it('returns variants unchanged when no cap is set', () => { + const v = ['hello world', 'hello'] + expect(capForScoring(v)).toBe(v) // same reference: pure pass-through + expect(capForScoring(v, 0)).toBe(v) + expect(capForScoring(v, -5)).toBe(v) + }) + + it('caps each variant to the first N chars', () => { + expect(capForScoring(['abcdefgh'], 3)).toEqual(['abc']) + expect(capForScoring(['ab'], 5)).toEqual(['ab']) // shorter than cap: kept + }) + + it('de-dupes variants that collapse to the same prefix', () => { + // two spellings that only diverge past the cut become one input to score + expect(capForScoring(['killer', 'killed'], 4)).toEqual(['kill']) + }) + + it('property: every capped variant is <= maxChars and idempotent', () => { + fc.assert( + fc.property( + fc.array(fc.string(), { maxLength: 6 }), + fc.integer({ min: 1, max: 50 }), + (variants, cap) => { + const capped = capForScoring(variants, cap) + // hard ceiling on every scored string + expect(capped.every((s) => s.length <= cap)).toBe(true) + // distinct (Set semantics) + expect(new Set(capped).size).toBe(capped.length) + // capping never grows the work set + expect(capped.length).toBeLessThanOrEqual(variants.length) + // re-capping the capped set changes nothing + expect(capForScoring(capped, cap)).toEqual(capped) + }, + ), + ) + }) +}) diff --git a/apps/moderation/src/pipeline/normalize.ts b/apps/moderation/src/pipeline/normalize.ts new file mode 100644 index 00000000..067a9b3f --- /dev/null +++ b/apps/moderation/src/pipeline/normalize.ts @@ -0,0 +1,97 @@ +// Normalization for allowlist lookup and for scoring. +// +// - normalizeForAllowlist mirrors apps/server/src/features/chat/chat.service.ts +// exactly (allowlist keys are stored in that normalized form). +// - scoringVariants defeats common evasion (leetspeak, stretched letters) by +// producing several spellings; the pipeline scores each and takes the +// per-label max. The ORIGINAL text is always what gets published. + +const LEET: Record = { + '0': 'o', + '1': 'i', + '3': 'e', + '4': 'a', + '5': 's', + '7': 't', + '8': 'b', + '!': 'i', + '@': 'a', + $: 's', +} + +/** + * Normalizes a message for allowlist comparison. Returns null for + * whitespace-only input (which the caller drops). + */ +export function normalizeForAllowlist(message: string): string | null { + const trimmed = message.trim() + if (trimmed === '') return null + + const lower = trimmed.toLowerCase() + + // Pure-punctuation messages: don't strip the trailing character. + if (/^[.!?]+$/.test(lower)) return lower + + if (lower.endsWith('.') || lower.endsWith('!') || lower.endsWith('?')) { + return lower.slice(0, -1) + } + + return lower +} + +function leetFold(text: string): string { + return text.toLowerCase().replace(/[0134578!@$]/g, (c) => LEET[c] ?? c) +} + +// '1'/'|'/'!' are ambiguous strokes: often 'i' (k1ll), but just as often 'l' +// (ki11 = kill) or 'i'. leetFold picks 'i'; this alternate picks 'l' so the +// other spelling gets scored too (red-team 2026-07-09: "ki11 yourself" leaked). +function leetFoldAltL(text: string): string { + // Map 1/|/! -> 'l' FIRST (before the default fold turns 1 into 'i'), then + // apply the remaining leet substitutions. + return text + .toLowerCase() + .replace(/[1|!]/g, 'l') + .replace(/[034578@$]/g, (c) => LEET[c] ?? c) +} + +// Collapses letter-spacing evasion ("k i l l you" -> "kill you") by joining runs +// of 2+ single letters. Harmless on normal text (real words aren't spelled out). +function despace(text: string): string { + return text.replace(/(?:\b[a-z]\b[ ]){2,}\b[a-z]\b/g, (s) => + s.replace(/ /g, ''), + ) +} + +/** + * Produces de-duplicated spellings to score: the original, leetspeak folds + * (both 'i' and 'l' readings of 1/|/!), a letter-despaced form, and + * repeat-squeezed forms ("kiiiill" -> "kiill" / "kil"). Scoring all of them and + * taking the per-label maximum catches evasion the raw text hides ("k1ll + * y0urs3lf", "ki11 yourself", "k i l l you"). + */ +export function scoringVariants(text: string): string[] { + const bases = [leetFold(text), leetFoldAltL(text)] + const out = new Set([text]) + for (const b of bases) { + for (const v of [b, despace(b)]) { + out.add(v) + out.add(v.replace(/([a-z])\1+/g, '$1$1')) // 3+ repeats -> 2 + out.add(v.replace(/([a-z])\1+/g, '$1')) // all repeats -> 1 + } + } + return [...out] +} + +/** + * Caps each scoring variant to its first `maxChars` characters and de-dupes + * (truncation can collapse variants that only differed past the cut). Bounds + * the guard's per-call cost on long messages — toxic signal is front-loaded + * and the deterministic tiers still scan the full text, so this trades only the + * unlikely late-in-a-wall-of-text ML hit for a hard latency ceiling. + * `maxChars` omitted / `<= 0` returns the variants unchanged (offline eval). + */ +export function capForScoring(variants: string[], maxChars?: number): string[] { + if (!maxChars || maxChars <= 0) return variants + return [...new Set(variants.map((v) => v.slice(0, maxChars)))] +} diff --git a/apps/moderation/src/pipeline/policy.ts b/apps/moderation/src/pipeline/policy.ts new file mode 100644 index 00000000..b9c57821 --- /dev/null +++ b/apps/moderation/src/pipeline/policy.ts @@ -0,0 +1,25 @@ +import type { ModerationPolicy } from './types.js' + +/** + * Default policy (ADR-5, revised: Qwen3Guard is the only model — toxic-bert + * and its per-label thresholds are gone). The guard's three-way verdict maps + * directly: Unsafe blocks, Controversial/unparseable goes to review, Safe + * publishes. Deterministic tiers (allowlist, blocklist/denylist, PII) decide + * before the guard ever runs and are unaffected by shadow mode. + * + * Ships with shadowMode ON: guard-blocks are logged as would-block and + * published, until the verdict quality is confirmed on live traffic (ADR-4). + */ +export const DEFAULT_POLICY: ModerationPolicy = { + shadowMode: true, + // Real-traffic reconstruction (685 days of queue transcripts): p99.9 load + // ≈0.8 msg/s and the worst minute ever ≈7.2 msg/s. Judgements that can't + // land inside the deadline FAIL CLOSED (reject + retry hint) — nothing + // unjudged is ever published; the shell also rejects early when the lane's + // backlog already guarantees a miss, so overload feels instant, not slow. + guardDeadlineMs: 5000, + // Judge the first 400 chars per variant. Real chat messages are well under + // this (BMP median is a handful of words); it only bites the long-message + // tail the load test flagged as the dominant model cost. Tunable via DB later. + scoreMaxChars: 400, +} diff --git a/apps/moderation/src/pipeline/rate-limit.test.ts b/apps/moderation/src/pipeline/rate-limit.test.ts new file mode 100644 index 00000000..89071b2b --- /dev/null +++ b/apps/moderation/src/pipeline/rate-limit.test.ts @@ -0,0 +1,201 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { canConsume, consume, newBucket, refill } from './rate-limit.js' +import type { RateLimitConfig, TokenBucket } from './types.js' + +const CFG: RateLimitConfig = { burst: 5, refillPerSec: 0.5 } // 1 token / 2s + +describe('token bucket — unit', () => { + it('starts full', () => { + expect(newBucket(CFG, 1000).tokens).toBe(5) + }) + + it('allows a burst of `burst` then rejects', () => { + let bucket = newBucket(CFG, 0) + for (let i = 0; i < 5; i++) { + const r = consume(bucket, CFG, 0) + expect(r.allowed).toBe(true) + if (r.allowed) bucket = r.bucket + } + const sixth = consume(bucket, CFG, 0) + expect(sixth.allowed).toBe(false) + if (!sixth.allowed) expect(sixth.retryAfterMs).toBe(2000) // 1 token at 0.5/s + }) + + it('refills one token after the refill interval', () => { + let bucket = newBucket(CFG, 0) + for (let i = 0; i < 5; i++) { + const r = consume(bucket, CFG, 0) + if (r.allowed) bucket = r.bucket + } + const after = consume(bucket, CFG, 2000) // +1 token + expect(after.allowed).toBe(true) + }) + + it('does not exceed burst no matter how long idle', () => { + const bucket = refill(newBucket(CFG, 0), CFG, 10_000_000) + expect(bucket.tokens).toBe(5) + }) + + it('does not gain tokens from a backwards clock', () => { + const b = refill({ tokens: 2, lastRefillMs: 1000 }, CFG, 500) + expect(b.tokens).toBe(2) + }) + + it('never moves lastRefillMs backwards, even when nowMs is stale (regression)', () => { + const stale = refill({ tokens: 2, lastRefillMs: 1000 }, CFG, 500) + expect(stale.lastRefillMs).toBe(1000) // NOT rewound to 500 + const equal = refill({ tokens: 2, lastRefillMs: 1000 }, CFG, 1000) + expect(equal.lastRefillMs).toBe(1000) + }) + + it('a late-resuming request with a stale nowMs cannot rewind the bucket and re-grant tokens to a later request (regression)', () => { + const cfg: RateLimitConfig = { burst: 1, refillPerSec: 0.5 } // 1 token / 2000ms + // t=0: full bucket, one token available. + let bucket = newBucket(cfg, 0) + // A slow request captures started=0, then (conceptually) awaits a ~5s + // guard judgement before it ever calls consume. + const slowRequestStarted = 0 + + // A concurrent, faster request for the SAME player arrives at t=100 and + // legitimately spends the only token before the slow request resumes. + const fast = consume(bucket, cfg, 100) + expect(fast.allowed).toBe(true) + bucket = fast.bucket + expect(bucket).toEqual({ tokens: 0, lastRefillMs: 100 }) + + // The slow request now resumes and consumes using its STALE started=0, + // which is behind the bucket's real lastRefillMs=100. + const slow = consume(bucket, cfg, slowRequestStarted) + expect(slow.allowed).toBe(false) // correctly still rate-limited + bucket = slow.bucket + expect(bucket.lastRefillMs).toBe(100) // must not rewind to 0 + + // A later request at t=2050 — only 1950ms after the REAL last refill + // (t=100) — must still be rejected: a full token needs 2000ms. A + // rewound lastRefillMs=0 would instead see 2050ms elapsed and wrongly + // re-grant a token. + const tooEarly = consume(bucket, cfg, 2050) + expect(tooEarly.allowed).toBe(false) + + // Once a full interval has genuinely elapsed since the real last + // refill, a token is available again. + const afterRealInterval = consume(bucket, cfg, 2101) + expect(afterRealInterval.allowed).toBe(true) + }) +}) + +describe('token bucket — properties', () => { + const arbConfig = fc.record({ + burst: fc.integer({ min: 1, max: 50 }), + refillPerSec: fc.double({ min: 0.01, max: 100, noNaN: true }), + }) + + it('tokens stay within [0, burst] across arbitrary consume sequences', () => { + fc.assert( + fc.property( + arbConfig, + fc.array(fc.nat({ max: 100_000 }), { minLength: 1, maxLength: 50 }), + (cfg, deltas) => { + let bucket = newBucket(cfg, 0) + let t = 0 + for (const d of deltas) { + t += d + const r = consume(bucket, cfg, t) + bucket = r.bucket + expect(bucket.tokens).toBeGreaterThanOrEqual(0) + expect(bucket.tokens).toBeLessThanOrEqual(cfg.burst) + } + }, + ), + ) + }) + + it('retryAfterMs is positive whenever a consume is rejected', () => { + fc.assert( + fc.property(arbConfig, fc.nat({ max: 1_000_000 }), (cfg, t) => { + const empty = { tokens: 0, lastRefillMs: t } + const r = consume(empty, cfg, t) + if (!r.allowed) expect(r.retryAfterMs).toBeGreaterThan(0) + }), + ) + }) +}) + +describe('canConsume — pure peek', () => { + it('full bucket -> true', () => { + expect(canConsume(newBucket(CFG, 1000), CFG, 1000)).toBe(true) + }) + + it('empty bucket at the same instant -> false', () => { + expect(canConsume({ tokens: 0, lastRefillMs: 1000 }, CFG, 1000)).toBe(false) + }) + + it('empty bucket +2000ms (0.5 tok/s) -> true', () => { + expect(canConsume({ tokens: 0, lastRefillMs: 1000 }, CFG, 3000)).toBe(true) + }) + + const arbConfig = fc.record({ + burst: fc.integer({ min: 1, max: 50 }), + refillPerSec: fc.double({ min: 0.01, max: 100, noNaN: true }), + }) + const arbBucket = fc.record({ + tokens: fc.double({ min: 0, max: 5, noNaN: true }), + lastRefillMs: fc.nat({ max: 1_000_000 }), + }) + + it('AGREEMENT: canConsume(b,c,n) === consume(b,c,n).allowed', () => { + fc.assert( + fc.property( + arbBucket, + arbConfig, + fc.nat({ max: 2_000_000 }), + (bucket, cfg, nowMs) => { + expect(canConsume(bucket, cfg, nowMs)).toBe( + consume(bucket, cfg, nowMs).allowed, + ) + }, + ), + ) + }) + + it('NO MUTATION: leaves the input bucket and the world unchanged', () => { + fc.assert( + fc.property( + arbBucket, + arbConfig, + fc.nat({ max: 2_000_000 }), + (bucket, cfg, nowMs) => { + const before = { ...bucket } + canConsume(bucket, cfg, nowMs) + expect(bucket).toEqual(before) + }, + ), + ) + // 100 consecutive peeks on a full bucket must not drift its consumability. + const full = newBucket(CFG, 0) + for (let i = 0; i < 100; i++) canConsume(full, CFG, 0) + expect(consume(full, CFG, 0).allowed).toBe(true) + }) + + it('MONOTONICITY: a bucket with <= tokens and >= lastRefillMs can only be as consumable', () => { + fc.assert( + fc.property( + arbBucket, + arbConfig, + fc.nat({ max: 2_000_000 }), + fc.double({ min: 0, max: 5, noNaN: true }), + fc.nat({ max: 2_000_000 }), + (b1, cfg, nowMs, tokenDelta, refillDelta) => { + const b2: TokenBucket = { + tokens: Math.max(0, b1.tokens - tokenDelta), + lastRefillMs: b1.lastRefillMs + refillDelta, + } + if (!canConsume(b1, cfg, nowMs)) { + expect(canConsume(b2, cfg, nowMs)).toBe(false) + } + }, + ), + ) + }) +}) diff --git a/apps/moderation/src/pipeline/rate-limit.ts b/apps/moderation/src/pipeline/rate-limit.ts new file mode 100644 index 00000000..207481bc --- /dev/null +++ b/apps/moderation/src/pipeline/rate-limit.ts @@ -0,0 +1,71 @@ +import type { RateLimitConfig, TokenBucket } from './types.js' + +// Pure token-bucket math. No clock of its own — `nowMs` is always passed in. + +/** A full bucket at time `nowMs`. */ +export function newBucket(config: RateLimitConfig, nowMs: number): TokenBucket { + return { tokens: config.burst, lastRefillMs: nowMs } +} + +/** + * Refills a bucket up to `nowMs` without consuming. Clamps to [0, burst] and + * never moves `lastRefillMs` backwards: a stale `nowMs <= lastRefillMs` + * returns the bucket UNCHANGED (no gained tokens, no rewritten timestamp). + * This matters beyond clock skew — a caller that captures `nowMs` before an + * await and writes the bucket back after (e.g. a rate-limit peek held across + * a slow guard judgement) must not rewind a timestamp a concurrent, later + * writer already advanced; doing so would let a subsequent request compute + * elapsed time from the rewound point and re-gain tokens nobody earned. + */ +export function refill( + bucket: TokenBucket, + config: RateLimitConfig, + nowMs: number, +): TokenBucket { + if (nowMs <= bucket.lastRefillMs) return bucket + const elapsedMs = nowMs - bucket.lastRefillMs + const gained = (elapsedMs / 1000) * config.refillPerSec + const tokens = Math.min(config.burst, bucket.tokens + gained) + return { tokens, lastRefillMs: nowMs } +} + +/** + * PURE PEEK: would `consume` succeed right now? Returns a boolean and NOTHING + * else — deliberately no bucket — so a caller physically cannot spend a token + * with it. `decideModeration`'s internal `consume` call stays the ONE place a + * token is ever spent. + * Invariant (property-tested): canConsume(b,c,n) === consume(b,c,n).allowed. + */ +export function canConsume( + bucket: TokenBucket, + config: RateLimitConfig, + nowMs: number, +): boolean { + return refill(bucket, config, nowMs).tokens >= 1 +} + +export type ConsumeResult = + | { allowed: true; bucket: TokenBucket } + | { allowed: false; bucket: TokenBucket; retryAfterMs: number } + +/** + * Refills then attempts to consume one token. On success the bucket loses a + * token; on failure the bucket is unchanged (except the refill timestamp) and + * `retryAfterMs` says how long until one token is available. + */ +export function consume( + bucket: TokenBucket, + config: RateLimitConfig, + nowMs: number, +): ConsumeResult { + const refilled = refill(bucket, config, nowMs) + if (refilled.tokens >= 1) { + return { + allowed: true, + bucket: { ...refilled, tokens: refilled.tokens - 1 }, + } + } + const deficit = 1 - refilled.tokens + const retryAfterMs = Math.ceil((deficit / config.refillPerSec) * 1000) + return { allowed: false, bucket: refilled, retryAfterMs } +} diff --git a/apps/moderation/src/pipeline/rewrite.test.ts b/apps/moderation/src/pipeline/rewrite.test.ts new file mode 100644 index 00000000..c98dfe4a --- /dev/null +++ b/apps/moderation/src/pipeline/rewrite.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { applyRewrites, parseRewrites } from './rewrite.js' + +const COCK = parseRewrites('cock => cocktail') + +describe('parseRewrites', () => { + it('parses one rule per line and skips comments/blanks', () => { + const rules = parseRewrites( + '# community vocabulary\ncock => cocktail\n\n# more\nbm => bad manners\n', + ) + expect(rules).toEqual([ + { from: 'cock', to: 'cocktail' }, + { from: 'bm', to: 'bad manners' }, + ]) + }) + + it('skips malformed lines and identity rules', () => { + expect(parseRewrites('no arrow here\ncock => cock\n=> x\n')).toEqual([]) + }) + + it('parses an !! unless guard into a case-insensitive regex', () => { + const rules = parseRewrites('cock => cocktail !! my\\s+cock') + expect(rules).toHaveLength(1) + expect(rules[0]?.unless).toBeInstanceOf(RegExp) + expect(rules[0]?.unless?.test('SUCK MY COCK')).toBe(true) + expect(rules[0]?.unless?.test('white cock?')).toBe(false) + }) + + it('drops the whole rule when the unless regex is malformed (fail closed)', () => { + expect(parseRewrites('cock => cocktail !! ((broken')).toEqual([]) + }) +}) + +describe('applyRewrites', () => { + it('rewrites on word boundaries, preserving the rest of the message', () => { + expect(applyRewrites('wanna do white cock?', COCK)).toBe( + 'wanna do white cocktail?', + ) + }) + + it('never re-matches inside the target word', () => { + expect(applyRewrites('cocktail deck anyone?', COCK)).toBe( + 'cocktail deck anyone?', + ) + // and a message that already says cocktail is a fixed point + const once = applyRewrites('suck my cock', COCK) + expect(once).toBe('suck my cocktail') + expect(applyRewrites(once, COCK)).toBe(once) + }) + + it('preserves simple casing', () => { + expect(applyRewrites('COCK?', COCK)).toBe('COCKTAIL?') + expect(applyRewrites('Cock deck', COCK)).toBe('Cocktail deck') + }) + + it('rewrites every occurrence', () => { + expect(applyRewrites('cock cock cock', COCK)).toBe( + 'cocktail cocktail cocktail', + ) + }) + + it('returns the input unchanged when nothing matches', () => { + expect(applyRewrites('gg wp', COCK)).toBe('gg wp') + expect(applyRewrites('peacock feathers', COCK)).toBe('peacock feathers') + }) + + it('escapes regex metacharacters in rule sources', () => { + const rules = parseRewrites('g.g => gg') + expect(applyRewrites('gag', rules)).toBe('gag') + expect(applyRewrites('g.g', rules)).toBe('gg') + }) + + describe('unless guard (anti-laundering, 2026-07-09)', () => { + // The production rule shape: rewrite deck talk, but NOT sexual frames — + // those must reach the guard raw so it can block them. + const GUARDED = parseRewrites( + 'cock => cocktail !! (my|your|ur)\\s+cock|\\b(suck|lick)\\w*\\b[\\s\\w]{0,20}\\bcock', + ) + + it('skips the rewrite in sexual frames so the guard sees the raw text', () => { + expect(applyRewrites('suck my cock', GUARDED)).toBe('suck my cock') + expect(applyRewrites('do you want my cock', GUARDED)).toBe( + 'do you want my cock', + ) + expect(applyRewrites('sucking ur cock', GUARDED)).toBe('sucking ur cock') + }) + + it('still rewrites deck-talk frames', () => { + expect(applyRewrites('wanna do white cock?', GUARDED)).toBe( + 'wanna do white cocktail?', + ) + expect(applyRewrites('cock deck anyone?', GUARDED)).toBe( + 'cocktail deck anyone?', + ) + expect(applyRewrites('wanna play cock?', GUARDED)).toBe( + 'wanna play cocktail?', + ) + }) + + it('the guard matches against the ORIGINAL message, not partial rewrites', () => { + // one message, both frames: guard hit disables the rule for the whole message + expect(applyRewrites('white cock? also suck my cock', GUARDED)).toBe( + 'white cock? also suck my cock', + ) + }) + }) +}) diff --git a/apps/moderation/src/pipeline/rewrite.ts b/apps/moderation/src/pipeline/rewrite.ts new file mode 100644 index 00000000..dcc62543 --- /dev/null +++ b/apps/moderation/src/pipeline/rewrite.ts @@ -0,0 +1,83 @@ +// Deterministic rewrite tier (pure core). Community-vocabulary corrections +// applied to the message BEFORE any judging — the rewritten text is what the +// allowlist/analyzer/guard see AND what gets published (`publishText`). This +// exists for words the guard cannot be taught past even with domain context +// (measured: "wanna do white cock?" stays Unsafe [Sexual] with glossary + +// real conversation — the Cocktail-deck abbreviation reads as crude to the +// model no matter what). Rewriting to the full term fixes the false block. +// +// Rules come from a human-edited file (REWRITES_PATH), one per line: +// cock => cocktail +// cock => cocktail !! (my|your)\s+cock|suck\w*[\s\w]{0,16}cock +// `#` lines are comments. Matching is case-insensitive on word boundaries — +// "cocktail" itself is NOT re-matched ("cock" inside it has no trailing word +// boundary). Simple case preservation: ALL-CAPS and Capitalized sources map +// to ALL-CAPS / Capitalized targets. +// +// The optional `!! ` suffix is an UNLESS guard: when the (case- +// insensitive) regex matches the original message, the rule is skipped for +// that whole message. This closes the laundering hole (found 2026-07-09): +// without it, "suck my cock" rewrote to "suck my cocktail", which the guard +// then judged Safe — the rewrite must not fire in clearly-sexual frames, so +// the guard sees the raw text and blocks it (measured: the tuned guard flags +// the raw forms Unsafe/Sexual). A malformed unless-regex disables its rule +// entirely (fail closed to "no rewrite" — the guard judges raw). + +export type RewriteRule = { from: string; to: string; unless?: RegExp } + +const escapeRegExp = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** Parses the rewrites file. Malformed lines are skipped, not fatal. */ +export function parseRewrites(text: string): RewriteRule[] { + const rules: RewriteRule[] = [] + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const m = /^(.+?)\s*=>\s*(.+?)(?:\s*!!\s*(.+))?$/.exec(trimmed) + if (!m) continue + const from = (m[1] as string).trim().toLowerCase() + const to = (m[2] as string).trim() + if (!from || !to || from === to.toLowerCase()) continue + if (m[3]) { + let unless: RegExp + try { + unless = new RegExp(m[3].trim(), 'i') + } catch { + // Malformed guard: skip the whole rule rather than rewriting + // without its safety condition. + continue + } + rules.push({ from, to, unless }) + } else { + rules.push({ from, to }) + } + } + return rules +} + +function matchCase(source: string, target: string): string { + if (source === source.toUpperCase() && /[A-Z]/.test(source)) + return target.toUpperCase() + if (source[0] === source[0]?.toUpperCase() && /[A-Z]/.test(source[0] ?? '')) + return (target[0]?.toUpperCase() ?? '') + target.slice(1) + return target +} + +/** + * Applies every rule on word boundaries; returns the input unchanged when + * nothing matches. A rule whose `unless` guard matches the ORIGINAL message + * is skipped entirely — the raw text flows on to the guard instead. + */ +export function applyRewrites( + message: string, + rules: readonly RewriteRule[], +): string { + let out = message + for (const rule of rules) { + if (rule.unless?.test(message)) continue + const re = new RegExp(`\\b${escapeRegExp(rule.from)}\\b`, 'gi') + out = out.replace(re, (hit) => matchCase(hit, rule.to)) + } + return out +} diff --git a/apps/moderation/src/pipeline/threat.test.ts b/apps/moderation/src/pipeline/threat.test.ts new file mode 100644 index 00000000..258d44f7 --- /dev/null +++ b/apps/moderation/src/pipeline/threat.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { findThreats } from './threat.js' + +// The demonstrated guard misses (2026-07-09) plus obvious siblings. These MUST +// be caught deterministically — the guard rated several of them Safe. +const MUST_BLOCK = [ + 'i will execute your family', + 'I will execute your fucking family', + 'i will fucking kill you', + 'im gonna kill you', + 'imma kill u', + 'i will murder your family', + 'execute your whole family', + 'i will behead you', // intent + you + 'strangle your kids', + 'i will slaughter your entire family', + 'k1ll your family', // leetspeak + 'i will kiiiill you', // stretched + 'i.will.kill.you', // punctuation as separators + 'i will k i l l you', // letter-spacing evasion + 'imma end your bloodline', // bloodline family target + 'murder your mom', + 'i will destroy your family', // tier B + family + 'imma end your whole family', + 'i will beat your mom', + // Game-scope exemption must NOT be a bypass template: qualifier not at the + // end, or aimed at family, still blocks. + 'ima kill you in this game and then in real life', + 'i will kill you in this game and irl too', + 'i will kill your family in this game', + 'ima kill you in your house', +] + +// Normal lobby speech / game-speak / hyperbole / mom-jokes with no directed +// person target. These must NOT block — this is the false-positive floor the +// guard fine-tune exists to protect, and the reason this tier is target-gated. +const MUST_NOT_BLOCK = [ + 'execute the combo', + "i'll kill this run", + 'gonna murder this boss', + 'destroy you', // tier B + bare you stays banter + 'i will destroy you at this game', + 'i destroyed you lol', + 'you killed that last hand', + "you're killing me", + 'this rng is murdering me', + 'gg i will kill it next time', + 'kill your combo', + 'execute your macro', + 'end your turn', + 'i will end my run', + 'my family loves this game', + 'skill issue', // 'skill' must not read as 'kill' + 'that boss will kill you', // no first-person intent -> guard's job, not ours + 'nice you beat me', + 'gg wp that was close', + 'fuck this rng man', + // Game-scoped match taunts (measured FP 2026-07-11): the qualifier ends the + // message, so the bare-you rung defers to the guard instead of hard-blocking. + 'ima kill you in this game', + 'i will kill you this run', + 'imma kill u in the next round lol', + 'i will kill you in balatro', +] + +describe('findThreats', () => { + it('blocks directed violent threats', () => { + for (const m of MUST_BLOCK) { + expect(findThreats(m).length, `should block: "${m}"`).toBeGreaterThan(0) + } + }) + + it('does not block banter / game-speak / hyperbole', () => { + for (const m of MUST_NOT_BLOCK) { + expect(findThreats(m), `should NOT block: "${m}"`).toEqual([]) + } + }) + + it('returns match spans with the offending phrase', () => { + const [m] = findThreats('i will execute your family') + expect(m.word).toContain('execute') + expect(m.endIndex).toBeGreaterThan(m.startIndex) + }) + + it('is total — never throws on odd input', () => { + for (const m of ['', ' ', '💀💀💀', '\n\n', 'a'.repeat(3000)]) { + expect(() => findThreats(m)).not.toThrow() + } + }) +}) diff --git a/apps/moderation/src/pipeline/threat.ts b/apps/moderation/src/pipeline/threat.ts new file mode 100644 index 00000000..d50bd934 --- /dev/null +++ b/apps/moderation/src/pipeline/threat.ts @@ -0,0 +1,212 @@ +import type { MatchRecord } from './types.js' + +// Deterministic violent-threat tier. Runs BEFORE the guard so a 0.6B model's +// fuzziness at the threat boundary can never let a directed threat through +// (measured 2026-07-09: the tuned guard rated "i will execute your family" and +// "i will fucking kill you" Safe). This tier is intentionally NARROW — it fires +// only on a violence verb aimed at a PERSON/FAMILY target, which is what keeps +// it from re-flagging the game-speak the guard fine-tune exists to allow +// ("execute the combo", "i'll kill this run", "destroy you" as trash-talk). +// +// Two rungs, both requiring a person target: +// A) lethal/unambiguous verbs (kill, murder, execute, behead, ...): +// - + a family target -> block (never benign) +// - + bare "you", WITH a first-person intent lead-in ("i will kill you") +// -> block. The intent lead-in rules out hyperbole ("that boss will +// kill you") whose subject is not the speaker. +// B) banter-capable verbs (destroy, end, beat, hurt, ...): +// - + a FAMILY target only -> block ("destroy you" stays banter, +// "destroy your family" does not). +// +// The guard still backs everything this tier does not match; this is a floor, +// not the whole net. New misses get pinned here and in the canary gate. + +const LEET: Record = { + '0': 'o', + '1': 'i', + '3': 'e', + '4': 'a', + '5': 's', + '7': 't', + '8': 'b', + '!': 'i', + '@': 'a', + $: 's', +} + +/** Lowercase, de-leet, reduce punctuation to spaces, and join letter-spacing + * ("k1ll.you", "kill you", "k i l l you" all normalize to "kill you"). */ +function normalize(text: string): string { + return text + .toLowerCase() + .replace(/[0134578!@$]/g, (c) => LEET[c] ?? c) + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/(?:\b[a-z]\b ){2,}\b[a-z]\b/g, (s) => s.replace(/ /g, '')) + .trim() +} + +/** Turns a word into a stretch-tolerant pattern: a single letter may repeat + * ("kiiill"), and a genuinely-doubled letter must still appear at least twice + * ("kill" -> k+i+l{2,}, so "kil"/"skil" can't collide with it). */ +function stretch(word: string): string { + let out = '' + for (let i = 0; i < word.length; ) { + const c = word[i] + let n = 1 + while (word[i + n] === c) n++ + out += `${c}${n >= 2 ? `{${n},}` : '+'}` + i += n + } + return out +} + +const alt = (words: string[]) => words.map(stretch).join('|') + +// A: lethal verbs, never benign in a directed sentence. +const VERBS_A = [ + 'kill', + 'murder', + 'execute', + 'behead', + 'decapitate', + 'slaughter', + 'strangle', + 'lynch', + 'massacre', + 'assassinate', + 'rape', + 'stab', + 'torture', + 'maim', + 'mutilate', + 'dismember', + 'crucify', + 'exterminate', + 'eviscerate', + 'butcher', +] +// B: violent but usable as trash-talk with a bare target -> family target only. +const VERBS_B = [ + 'destroy', + 'end', + 'beat', + 'bash', + 'smash', + 'wreck', + 'hurt', + 'harm', + 'gut', + 'choke', + 'finish', + 'kidnap', + 'gas', +] + +// Family / loved-one nouns. A possessive ("your"/"ur") + up to two filler words +// (adjectives, "fucking"/"whole") + a family noun. +const FAMILY_NOUNS = [ + 'family', + 'fam', + 'mom', + 'mommy', + 'mother', + 'mum', + 'dad', + 'daddy', + 'father', + 'kid', + 'kids', + 'child', + 'children', + 'son', + 'daughter', + 'sister', + 'sis', + 'brother', + 'bro', + 'wife', + 'husband', + 'parent', + 'parents', + 'grandma', + 'grandmother', + 'grandpa', + 'grandfather', + 'baby', + 'gf', + 'girlfriend', + 'bf', + 'boyfriend', + 'bloodline', + 'lineage', + 'household', +] +const POSSESSIVE = '(?:your|ur|yr|ya)' +const FAMILY = `(?:${POSSESSIVE}(?:\\s+\\w+){0,2}\\s+(?:${alt(FAMILY_NOUNS)})|everyone\\s+(?:you|u)\\s+lov(?:e|es)|(?:your|ur)\\s+lov(?:ed)?\\s+ones)` +// Bare second-person target (not "yourself" — that's the self-harm denylist). +const YOU = '(?:you|u|ya|yall|y\\s?all)' +// First-person intent lead-in that marks the speaker as the actor. +const INTENT = '\\b(?:i|im|ima|imma|we)\\b' +// 0-3 filler words between pieces. +const GAP = '(?:\\s+\\w+){0,3}?\\s+' + +const bound = (p: string) => `\\b(?:${p})\\b` + +const RE_A_FAMILY = new RegExp(`${bound(alt(VERBS_A))}${GAP}${FAMILY}`, 'g') +const RE_A_YOU = new RegExp( + `${INTENT}${GAP}${bound(alt(VERBS_A))}${GAP}${YOU}\\b`, + 'g', +) +const RE_B_FAMILY = new RegExp(`${bound(alt(VERBS_B))}${GAP}${FAMILY}`, 'g') +const PATTERNS: Array<{ kind: string; re: RegExp; youRung?: boolean }> = [ + { kind: 'threat_lethal', re: RE_A_FAMILY }, + { kind: 'threat_lethal', re: RE_A_YOU, youRung: true }, + { kind: 'threat_violent', re: RE_B_FAMILY }, +] + +// Game-scope exemption for the bare-"you" rung ONLY ("ima kill you in this +// game" is a match taunt, measured live 2026-07-11). Deliberately narrow so it +// can't become a bypass template: the qualifier must END the message (bar a +// short banter tail) — "kill you in this game and then irl" still blocks. +// Family-targeted threats are NEVER exempted. Runs on the normalized text. +const GAME_SCOPE_TAIL = + /^\s+(?:in\s+)?(?:(?:this|the|that|next)\s+){1,2}(?:game|run|round|match|lobby)(?:\s+(?:lol|lmao|haha|xd|bro|dude|man))*$/ +const IN_GAME_TAIL = + /^\s+in\s+(?:game|balatro)(?:\s+(?:lol|lmao|haha|xd|bro|dude|man))*$/ + +function isGameScoped(norm: string, matchEnd: number): boolean { + const tail = norm.slice(matchEnd) + return GAME_SCOPE_TAIL.test(tail) || IN_GAME_TAIL.test(tail) +} + +/** + * Finds directed violent threats in `text`. Returns one MatchRecord per + * distinct matched span (indices into the normalized form; `word` is the + * matched phrase, for the audit log). Empty array = no threat. + */ +export function findThreats(text: string): MatchRecord[] { + const norm = normalize(text) + if (norm === '') return [] + const out: MatchRecord[] = [] + const seen = new Set() + for (const { re, youRung } of PATTERNS) { + re.lastIndex = 0 + for (const m of norm.matchAll(re)) { + const end = (m.index ?? 0) + m[0].length + // Bare-you matches that are explicitly game-scoped fall through to + // the guard instead of hard-blocking (see GAME_SCOPE_TAIL above). + if (youRung && isGameScoped(norm, end)) continue + const word = m[0].trim() + const key = `${m.index}:${word}` + if (seen.has(key)) continue + seen.add(key) + out.push({ + word, + startIndex: m.index ?? 0, + endIndex: end, + }) + } + } + return out +} diff --git a/apps/moderation/src/pipeline/types.ts b/apps/moderation/src/pipeline/types.ts new file mode 100644 index 00000000..1e031864 --- /dev/null +++ b/apps/moderation/src/pipeline/types.ts @@ -0,0 +1,134 @@ +// --- Rate limiting --- + +/** + * Token bucket state, carried as plain data so the decision core stays pure. + * `tokens` is the current fill; `lastRefillMs` is when it was last computed. + */ +export type TokenBucket = { + tokens: number + lastRefillMs: number +} + +export type RateLimitConfig = { + /** Maximum tokens (burst size). */ + burst: number + /** Tokens regenerated per second. */ + refillPerSec: number +} + +// --- Moderation policy --- + +export type ModerationPolicy = { + /** + * Shadow mode: when true, a guard-block decision is downgraded to `review` + * (published) and the verdict records `wouldHaveBlocked`. Deterministic + * blocklist/denylist rejections are unaffected — they enforce from day one. + */ + shadowMode: boolean + /** + * Wall-clock budget for the guard judgement inside /moderate. When the + * judgement doesn't land in time the shell passes `{skipped: 'deadline'}` + * and the decision FAILS CLOSED: no verdict, no publish — the message is + * rejected with band `guard_unavailable` (owner's rule: nothing unjudged + * ever appears in chat). 0/negative = wait indefinitely. + */ + guardDeadlineMs: number + /** + * Cap (in characters) on the input fed to the guard, applied per + * normalization variant. Toxic signal appears early and the server already + * bounds a message at 2000 chars, so judging a long wall of text end-to-end + * is pure cost and an easy abuse vector (paste text to inflate LLM cost). + * Only the guard input is capped; the deterministic tiers (obscenity, + * denylist, PII/contact) still scan the FULL message, so nothing that + * must-never-be-missed is affected. Omit / `0` / negative = no cap. + */ + scoreMaxChars?: number +} + +// --- Guard input --- + +/** + * Qwen3Guard's assessment of the message, as plain data (the engine lives in + * the shell; the decision core never sees the model). `unknown` = the model + * answered with something unparseable — treated like `Controversial` + * (publish + human review), never like `Safe`. + */ +export type GuardSafetyLevel = 'Safe' | 'Unsafe' | 'Controversial' | 'unknown' + +/** + * The guard's contribution to a decision. `{skipped}` records WHY no + * judgement exists (deadline, engine down, backlog, rate-limited before + * judging, a cheaper deterministic tier already decided, ...) — the decision + * then fails CLOSED (band `guard_unavailable`, nothing published). There is + * deliberately no bare `null` variant: every caller that skips judging must + * say why, so `decideModeration`'s fail-closed branch always sees a + * `{skipped}` marker instead of silently falling through to `allow`. + */ +export type GuardInput = + | { safety: GuardSafetyLevel; categories: string[] } + | { skipped: string } + +// --- Safety input (PII / contact-exchange tier, ws08) --- + +/** + * A deterministic safety finding, adapted from the safety module's matches. + * `red` rejects the message outright; `orange` forces at least a review band. + */ +export type SafetySignal = { + severity: 'red' | 'orange' + kind: string +} + +// --- Decision output --- + +export type Band = + | 'preset' + | 'threat_block' + | 'blocklist' + | 'safety_block' + | 'guard_block' + | 'guard_unavailable' + | 'review' + | 'clean' + +export type MatchRecord = { + word: string + startIndex: number + endIndex: number +} + +/** + * The pure decision core's per-message verdict. There is no database on this + * branch (verdict-only, docs/13) — the shell (service.ts) reads a handful of + * these fields into the content-free JSONL line handed to `onVerdict`; this + * type itself is never serialized wholesale. + */ +export type VerdictJson = { + v: 2 + band: Band + allowlisted: boolean + threatMatches?: MatchRecord[] + obscenityMatches?: MatchRecord[] + safetySignals?: SafetySignal[] + guardSafety?: GuardSafetyLevel + guardCategories?: string[] + guardSkipped?: string + wouldHaveBlocked?: boolean +} + +export type Decision = { + decision: 'allow' | 'reject' + band: Band + verdict: VerdictJson +} + +export type DecisionInput = { + message: string + nowMs: number + isAllowlisted: boolean + threatMatches: MatchRecord[] + obscenityMatches: MatchRecord[] + safetySignals: SafetySignal[] + guard: GuardInput + policy: ModerationPolicy +} diff --git a/apps/moderation/src/safety/contact-exchange.test.ts b/apps/moderation/src/safety/contact-exchange.test.ts new file mode 100644 index 00000000..e0c2530d --- /dev/null +++ b/apps/moderation/src/safety/contact-exchange.test.ts @@ -0,0 +1,330 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { + CONTACT_PATTERNS, + INTENT_PHRASES, + detectContactExchange, + foldObfuscations, +} from './contact-exchange.js' +import type { SafetyMatchKind } from './contact-exchange.js' + +function kinds(text: string): SafetyMatchKind[] { + return detectContactExchange(text).map((m) => m.kind) +} + +describe('foldObfuscations', () => { + it('folds bracket/paren dot markers', () => { + expect(foldObfuscations('mail me at name[dot]gmail[dot]com')).toContain( + 'gmail.com', + ) + expect(foldObfuscations('name(dot)gmail(dot)com')).toContain('gmail.com') + expect(foldObfuscations('name[.]gmail[.]com')).toContain('gmail.com') + }) + + it('folds spelled "dot" between words', () => { + expect(foldObfuscations('check balatro dot wiki')).toContain('balatro.wiki') + }) + + it('folds a two-dot chain ("co dot uk"-style)', () => { + expect( + foldObfuscations('reach me at name dot site dot co dot uk'), + ).toContain('site.co.uk') + }) + + it('folds " at " only in email-shaped context (dotted domain present)', () => { + expect(foldObfuscations('name at gmail dot com')).toContain( + 'name@gmail.com', + ) + // no trailing dotted domain -> "at" must NOT fold + expect(foldObfuscations('look at this')).toBe('look at this') + expect(foldObfuscations('shooting at me')).toBe('shooting at me') + }) + + it('folds bracket "at" markers only in email-shaped context', () => { + expect(foldObfuscations('name[at]gmail.com')).toContain('name@gmail.com') + }) + + it('folds spelled-out digit runs (3+ words)', () => { + expect(foldObfuscations('five five five one two three four')).toContain( + '5551234', + ) + }) + + it('does not fold a lone digit word', () => { + expect(foldObfuscations('I have two dogs and one cat')).toBe( + 'I have two dogs and one cat', + ) + }) + + it('folds spaced-out single digits (7+)', () => { + expect(foldObfuscations('call 5 5 5 1 2 3 4 now')).toContain('5551234') + }) + + it('does not fold a short spaced digit run (under 7)', () => { + expect(foldObfuscations('ante 1 2 3')).toBe('ante 1 2 3') + }) + + it('folds fullwidth characters to ASCII', () => { + expect(foldObfuscations('gmail.com')).toBe('gmail.com') + }) + + it('property: never throws for arbitrary (incl. unicode) input', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(() => foldObfuscations(s)).not.toThrow() + }), + ) + }) +}) + +describe('detectContactExchange — email', () => { + it('matches a plain email', () => { + expect(kinds('reach me at bob@example.com')).toContain('email') + }) + + it('matches an obfuscated email (bracket dot)', () => { + expect(kinds('bob[dot]smith[at]example[dot]com')).toContain('email') + }) + + it('matches an obfuscated email (spelled dot/at)', () => { + expect(kinds('bob at example dot com')).toContain('email') + }) +}) + +describe('detectContactExchange — phone', () => { + it('matches a standard hyphenated phone number', () => { + expect(kinds('call me at 555-123-4567')).toContain('phone') + }) + + it('matches a parenthesized area code', () => { + expect(kinds('(555) 123-4567 is my number')).toContain('phone') + }) + + it('matches spelled-out digits', () => { + expect(kinds('five five five one two three four')).toContain('phone') + }) + + it('matches spaced-out single digits', () => { + expect(kinds('5 5 5 1 2 3 4')).toContain('phone') + }) + + it('does NOT match short game-speak numbers', () => { + expect(kinds('gg 42 points')).not.toContain('phone') + expect(kinds('i played 8888 hands')).not.toContain('phone') + }) +}) + +describe('detectContactExchange — ip', () => { + it('matches a dotted-quad IP', () => { + expect(kinds('connect to 192.168.1.1 please')).toContain('ip') + }) + + it('does not double-report an IP as phone', () => { + const matches = detectContactExchange('connect to 192.168.1.1 please') + expect(matches.filter((m) => m.kind === 'phone')).toHaveLength(0) + }) +}) + +describe('detectContactExchange — url', () => { + it('matches an http(s) url', () => { + expect(kinds('go to https://example.com/x now')).toContain('url') + }) + + it('matches a www url', () => { + expect(kinds('go to www.example.com now')).toContain('url') + }) + + it('matches a bare domain with a known TLD', () => { + expect(kinds('check balatro.wiki for seeds')).toContain('url') + }) + + it('does NOT match plain words with no dot present', () => { + expect(kinds('check the balatro wiki')).not.toContain('url') + }) + + it('matches an obfuscated bare domain ("dot" spelled out)', () => { + expect(kinds('check balatro dot wiki for seeds')).toContain('url') + }) +}) + +describe('detectContactExchange — invite_link', () => { + it('matches a discord.gg invite', () => { + expect(kinds('join us at discord.gg/abc123')).toContain('invite_link') + }) + + it('matches a discord.com/invite link', () => { + expect(kinds('discord.com/invite/abc123')).toContain('invite_link') + }) + + it('reports the invite as invite_link, not a generic url', () => { + const matches = detectContactExchange('join us at discord.gg/abc123') + expect(matches.filter((m) => m.kind === 'url')).toHaveLength(0) + }) +}) + +describe('detectContactExchange — social_handle', () => { + it('matches a discord discriminator tag', () => { + expect(kinds('add me, xyz#1234')).toContain('social_handle') + }) + + it('matches "@handle on "', () => { + expect(kinds('hit me up @coolkid99 on snap')).toContain('social_handle') + expect(kinds('@player1 on telegram')).toContain('social_handle') + }) + + it('matches "on discord: handle"', () => { + expect(kinds('on discord: xyz')).toContain('social_handle') + }) + + it('does NOT match a bare Balatro seed string', () => { + expect(kinds('the seed is ALEEB7AM')).not.toContain('social_handle') + expect(kinds('try seed ALEEB7AM next run')).not.toContain('social_handle') + }) +}) + +describe('detectContactExchange — intent phrases (orange)', () => { + const cases: Array<[string, string]> = [ + ['add me on snapchat', 'add_me_on'], + ['dm me later', 'dm_me'], + ['message me on discord', 'message_me_on'], + ["what's your discord", 'whats_your_contact'], + ['whats ur number', 'whats_ur'], + ['send me a pic', 'send_pic'], + ['how old are you', 'how_old'], + ['where do you live', 'where_live'], + ['what school do you go to', 'what_school'], + ['are your parents around', 'parents'], + ["let's talk somewhere else", 'talk_elsewhere'], + ['lets move to whatsapp', 'move_to'], + ['off platform please', 'off_platform'], + ] + + for (const [text] of cases) { + it(`flags orange intent for: "${text}"`, () => { + const matches = detectContactExchange(text) + expect(matches.some((m) => m.severity === 'orange')).toBe(true) + expect(matches.some((m) => m.kind === 'intent_phrase')).toBe(true) + }) + } + + it('every declared intent phrase label is reachable by its own pattern', () => { + for (const { pattern, label } of INTENT_PHRASES) { + pattern.lastIndex = 0 + expect(label.length).toBeGreaterThan(0) + } + }) + + it('does not flag ordinary chat as an intent phrase', () => { + const matches = detectContactExchange('nice hand, gg well played') + expect(matches.some((m) => m.kind === 'intent_phrase')).toBe(false) + }) +}) + +describe('detectContactExchange — severity', () => { + it('contact matches and intent phrases are orange (review, not hard-block)', () => { + const matches = detectContactExchange('email me at bob@example.com, dm me') + const email = matches.find((m) => m.kind === 'email') + const intent = matches.find((m) => m.kind === 'intent_phrase') + expect(email?.severity).toBe('orange') + expect(intent?.severity).toBe('orange') + }) +}) + +describe('detectContactExchange — match record shape', () => { + it('reports span/startIndex/endIndex/pattern consistent with the folded text', () => { + const folded = foldObfuscations('bob@example.com') + const [match] = detectContactExchange('bob@example.com') + expect(match.span).toBe(folded.slice(match.startIndex, match.endIndex)) + expect(match.pattern.length).toBeGreaterThan(0) + }) + + it('returns matches sorted by startIndex', () => { + const matches = detectContactExchange( + 'bob@example.com then call 555-123-4567 then dm me', + ) + const indices = matches.map((m) => m.startIndex) + expect(indices).toEqual([...indices].sort((a, b) => a - b)) + }) + + it('does not overlap-report the same span under two kinds', () => { + const matches = detectContactExchange( + 'bob@example.com then www.example.com then dm me', + ) + for (let i = 0; i < matches.length; i++) { + for (let j = i + 1; j < matches.length; j++) { + const a = matches[i] + const b = matches[j] + const overlap = a.startIndex < b.endIndex && b.startIndex < a.endIndex + expect(overlap).toBe(false) + } + } + }) +}) + +describe('detectContactExchange — totality property', () => { + it('never throws for arbitrary (incl. unicode) input and returns well-formed matches', () => { + fc.assert( + fc.property(fc.string(), (s) => { + const matches = detectContactExchange(s) + expect(Array.isArray(matches)).toBe(true) + for (const m of matches) { + expect(m.startIndex).toBeGreaterThanOrEqual(0) + expect(m.endIndex).toBeGreaterThan(m.startIndex) + expect(['red', 'orange']).toContain(m.severity) + } + }), + ) + }) +}) + +describe('RE2 safety — adversarial latency', () => { + // A 10KB adversarial string mixing long digit runs, repeated punctuation, + // and a stretched-out non-matching prefix designed to maximize backtracking + // in a naively-written regex. Every exported pattern must resolve linearly. + const ADVERSARIAL = `${'a'.repeat(2000)}!${'1'.repeat(2000)} ${'5 '.repeat(2000)}${'.'.repeat(2000)}${'@'.repeat(1000)}` + + it('every CONTACT_PATTERNS regex completes linearly (no ReDoS) on adversarial input', () => { + for (const { regex, kind } of CONTACT_PATTERNS) { + regex.lastIndex = 0 + const start = performance.now() + // exhaust all matches, mirroring how `collect` drives the regex + let m = regex.exec(ADVERSARIAL) + let guard = 0 + while (m !== null && guard < 100_000) { + if (m[0].length === 0) regex.lastIndex += 1 + m = regex.exec(ADVERSARIAL) + guard++ + } + const elapsed = performance.now() - start + expect.soft(elapsed, `${kind}: ${regex.source}`).toBeLessThan(1000) + } + }) + + it('every INTENT_PHRASES regex completes linearly (no ReDoS) on adversarial input', () => { + for (const { pattern, label } of INTENT_PHRASES) { + pattern.lastIndex = 0 + const start = performance.now() + let m = pattern.exec(ADVERSARIAL) + let guard = 0 + while (m !== null && guard < 100_000) { + if (m[0].length === 0) pattern.lastIndex += 1 + m = pattern.exec(ADVERSARIAL) + guard++ + } + const elapsed = performance.now() - start + expect.soft(elapsed, label).toBeLessThan(1000) + } + }) + + it('the full detectContactExchange pipeline completes linearly (no ReDoS) on adversarial input', () => { + const start = performance.now() + detectContactExchange(ADVERSARIAL) + expect(performance.now() - start).toBeLessThan(1000) + }) + + it('foldObfuscations completes linearly (no ReDoS) on adversarial input', () => { + const start = performance.now() + foldObfuscations(ADVERSARIAL) + expect(performance.now() - start).toBeLessThan(1000) + }) +}) diff --git a/apps/moderation/src/safety/contact-exchange.ts b/apps/moderation/src/safety/contact-exchange.ts new file mode 100644 index 00000000..a07e570b --- /dev/null +++ b/apps/moderation/src/safety/contact-exchange.ts @@ -0,0 +1,336 @@ +// Deterministic PII / contact-exchange / doxxing safety tier (ws08). +// +// Runs on already-normalized text (the pipeline's normalize tier folds leet- +// speak, stretched letters, etc. — see ../pipeline/normalize.ts). This module +// additionally folds obfuscations specific to contact-info evasion (spelled +// digits, spaced digits, "dot"/"at" markers, fullwidth chars) that the +// general scoring normalizer doesn't handle, then runs a fixed battery of +// RE2-safe regexes. Every regex here is a single level of quantification over +// disjoint character classes (or a bounded `{0,n}` chain) — never a quantifier +// nested inside another quantifier over an overlapping alphabet — so none of +// them can exhibit catastrophic backtracking regardless of input. +// +// Per the design review (knowledge/chat-moderation/07-…, item 1): birthdates +// are never stored, so 16-17 can't be distinguished from adults — these +// protections apply to ALL users, not just minors. + +export type SafetyMatchKind = + | 'email' + | 'phone' + | 'ip' + | 'url' + | 'invite_link' + | 'social_handle' + | 'intent_phrase' + +export type SafetySeverity = 'red' | 'orange' + +export type SafetyMatch = { + kind: SafetyMatchKind + severity: SafetySeverity + span: string + startIndex: number + endIndex: number + pattern: string +} + +// --- Obfuscation folding --- + +const FULLWIDTH = /[!-~]/g + +function foldFullwidth(text: string): string { + return text.replace(FULLWIDTH, (c) => + String.fromCharCode(c.charCodeAt(0) - 0xfee0), + ) +} + +// A single dot-obfuscation marker: the spelled word or a bracket/paren form. +// Shared by the generic dot fold and the spaced-"at" email fold below. +const DOT_MARKER_ALT = '(?:\\bdot\\b|\\[dot\\]|\\(dot\\)|\\[\\.\\]|\\(\\.\\))' + +// "google dot com", "google[dot]com", "google(dot)com", "google[.]com" -> "google.com". +// Unconditional (unlike "at" below) — "dot" as an evasion marker has no benign +// reading worth protecting against, and downstream detectors still require a +// real domain/TLD shape before anything fires. +const DOT_MARKER = new RegExp( + `([a-z0-9-]+)\\s*${DOT_MARKER_ALT}\\s*([a-z0-9-]+)`, + 'gi', +) + +function foldDotMarkers(text: string): string { + // A single global pass only folds one "dot" per pair (the matched right-hand + // word is consumed and unavailable to start the next match). Running twice + // folds a chain like "co dot uk" that follows an already-folded "google.co". + let out = text.replace(DOT_MARKER, '$1.$2') + out = out.replace(DOT_MARKER, '$1.$2') + return out +} + +// "[at]"/"(at)" have no normal-English reading, so they fold unconditionally. +const BRACKET_AT = /\s*(?:\[at\]|\(at\))\s*/gi + +function foldBracketAt(text: string): string { + return text.replace(BRACKET_AT, '@') +} + +// Plain " at " only folds to "@" when the domain that follows ALSO spells out +// its dot (e.g. "bob at gmail dot com"). If the domain already has a literal +// "." ("discord.gg", "balatro.wiki"), " at " is virtually always the ordinary +// English preposition ("join us at discord.gg/abc123", "look at this") — a +// real dot there is not itself evidence of an obfuscated email, so folding on +// dot-shape alone would turn every "at " sentence into a false email +// match. Requiring the dot ITSELF to be spelled/bracketed keeps this scoped to +// genuine spell-it-all-out obfuscation. +const SPACED_AT_SPELLED_EMAIL = new RegExp( + `([a-z0-9_.%+-]+)\\s+at\\s+([a-z0-9-]+(?:\\s*${DOT_MARKER_ALT}\\s*[a-z0-9-]+){1,4})`, + 'gi', +) + +function foldSpacedAtSpelledEmail(text: string): string { + return text.replace( + SPACED_AT_SPELLED_EMAIL, + (_match, user: string, domain: string) => + `${user}@${domain.replace(new RegExp(`\\s*${DOT_MARKER_ALT}\\s*`, 'gi'), '.')}`, + ) +} + +const DIGIT_WORDS: Record = { + zero: '0', + one: '1', + two: '2', + three: '3', + four: '4', + five: '5', + six: '6', + seven: '7', + eight: '8', + nine: '9', +} +const DIGIT_WORD_ALT = Object.keys(DIGIT_WORDS).join('|') + +// "five five five one two three four" -> "5551234". Requires 3+ digit words +// in a row (single-char separator per repetition, never a quantified +// separator) so an ordinary "I have two dogs and one cat" never folds. +const SPELLED_DIGIT_RUN = new RegExp( + `\\b(?:${DIGIT_WORD_ALT})(?:[\\s-](?:${DIGIT_WORD_ALT})){2,}\\b`, + 'gi', +) + +function foldSpelledDigits(text: string): string { + return text.replace(SPELLED_DIGIT_RUN, (match) => + match + .split(/[\s-]/) + .map((word) => DIGIT_WORDS[word.toLowerCase()] ?? word) + .join(''), + ) +} + +// "5 5 5 1 2 3 4" -> "5551234". Requires 7+ digits total. +const SPACED_DIGIT_RUN = /\b\d(?:[\s.-]\d){6,}\b/g + +function foldSpacedDigits(text: string): string { + return text.replace(SPACED_DIGIT_RUN, (match) => match.replace(/[\s.-]/g, '')) +} + +/** + * Folds contact-exchange obfuscations that the pipeline's general scoring + * normalizer doesn't handle: fullwidth chars, "dot"/"at" markers, spelled-out + * and spaced-out digit sequences. Exported for direct testing. + */ +export function foldObfuscations(text: string): string { + let out = foldFullwidth(text) + out = foldBracketAt(out) + out = foldSpacedAtSpelledEmail(out) + out = foldDotMarkers(out) + out = foldSpelledDigits(out) + out = foldSpacedDigits(out) + return out +} + +// --- Deterministic detectors (contact exchange -> orange/review) --- + +const EMAIL = /\b[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+){0,3}\.[a-z]{2,}\b/gi + +const DISCORD_INVITE = + /\b(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+\b/gi + +const URL_SCHEME = /\bhttps?:\/\/[^\s<>"']+/gi + +const URL_WWW = /\bwww\.[a-z0-9-]+(?:\.[a-z0-9-]+){0,3}\.[a-z]{2,}\b/gi + +// Deliberately not exhaustive — a short, curated list of TLDs common enough in +// player-facing links to be worth flagging. Kept short on purpose: every entry +// widens the bare-domain matcher's false-positive surface (any "word.word" +// with a listed suffix fires), so this is a precision/recall tradeoff, not an +// oversight. Easy to extend once shadow data shows misses. +const COMMON_TLDS = [ + 'com', + 'net', + 'org', + 'io', + 'gg', + 'co', + 'wiki', + 'app', + 'dev', + 'me', + 'tv', + 'info', + 'biz', + 'xyz', + 'link', + 'edu', + 'gov', + 'us', + 'uk', +] + +const URL_BARE = new RegExp( + `\\b[a-z0-9-]+(?:\\.[a-z0-9-]+){0,3}\\.(?:${COMMON_TLDS.join('|')})\\b`, + 'gi', +) + +const IP = + /\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/g + +// "(555) 123-4567" — parenthesized area code, checked before the general run +// so its parens aren't left dangling in the plain digit-run match. +const PHONE_PAREN = /\(\d{3}\)[\s.-]?\d{3}[\s.-]?\d{4}\b/g + +// Any 7+ digit run allowing space/dot/dash separators. Intentionally broad +// (per spec) — a bare 7+ digit number in chat (e.g. a large score) will also +// match; see the design-decision note in the PR description. +const PHONE_RUN = /\b\d(?:[\s.-]?\d){6,}\b/g + +// "handle#1234" — Discord-style tag. Requires the literal discriminator +// suffix, so it never fires on a bare alphanumeric token (e.g. a Balatro seed +// like "ALEEB7AM" has no "#NNNN" suffix). +const DISCORD_TAG = /\b[a-z0-9_.]{2,32}#\d{4}\b/gi + +// "@handle on discord/insta/snap/telegram/kik/whatsapp/signal" +const HANDLE_ON_PLATFORM = + /@[a-z0-9_.]{2,32}\s+on\s+(?:discord|insta(?:gram)?|snap(?:chat)?|telegram|kik|whatsapp|signal)\b/gi + +// "on discord: handle" / "on discord handle" +// The trailing `\b` right after the platform alternation is load-bearing: it +// forces the optional "chat"/"gram" suffixes to be consumed as part of the +// platform name (not left over for the alternation to "shortchange" so the +// handle-chars class can absorb them) — without it, "on snapchat" alone (no +// handle at all) matches with platform="snap" + handle="chat". +const ON_PLATFORM_HANDLE = + /\bon\s+(?:discord|insta(?:gram)?|snap(?:chat)?|telegram|kik|whatsapp|signal)\b\s*[:-]?\s*[a-z0-9_.#]{2,37}\b/gi + +// Priority order matters: it resolves overlap conflicts (e.g. an IP's digits +// would also shape-match the phone run; checking `ip` first consumes that +// span so `phone` never re-reports it). Earlier entries win ties. +export const CONTACT_PATTERNS: Array<{ kind: SafetyMatchKind; regex: RegExp }> = + [ + { kind: 'email', regex: EMAIL }, + { kind: 'invite_link', regex: DISCORD_INVITE }, + { kind: 'url', regex: URL_SCHEME }, + { kind: 'url', regex: URL_WWW }, + { kind: 'ip', regex: IP }, + { kind: 'url', regex: URL_BARE }, + { kind: 'phone', regex: PHONE_PAREN }, + { kind: 'phone', regex: PHONE_RUN }, + { kind: 'social_handle', regex: DISCORD_TAG }, + { kind: 'social_handle', regex: HANDLE_ON_PLATFORM }, + { kind: 'social_handle', regex: ON_PLATFORM_HANDLE }, + ] + +// --- Intent phrases (orange) --- +// +// Contact-solicitation / grooming-adjacent intent. Kept as a plain data array +// (pattern + label) so it's swappable for a DB-loaded list later without +// touching the detector. +export const INTENT_PHRASES: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /add me on\b/gi, label: 'add_me_on' }, + { pattern: /\bdm me\b/gi, label: 'dm_me' }, + { pattern: /message me on\b/gi, label: 'message_me_on' }, + { + pattern: + /what'?s your (?:discord|snap(?:chat)?|insta(?:gram)?|number|telegram)\b/gi, + label: 'whats_your_contact', + }, + { pattern: /\bwhats ur\b/gi, label: 'whats_ur' }, + { + pattern: /send\s+(?:me\s+)?(?:a\s+)?(?:pics?|photo)\b/gi, + label: 'send_pic', + }, + { pattern: /how old are you\b/gi, label: 'how_old' }, + { pattern: /where do you live\b/gi, label: 'where_live' }, + { pattern: /what school\b/gi, label: 'what_school' }, + { pattern: /are your parents\b/gi, label: 'parents' }, + { pattern: /let'?s talk somewhere else\b/gi, label: 'talk_elsewhere' }, + { pattern: /lets? move to\b/gi, label: 'move_to' }, + { pattern: /off[\s-]?platform\b/gi, label: 'off_platform' }, +] + +function overlapsAny( + start: number, + end: number, + accepted: SafetyMatch[], +): boolean { + return accepted.some((a) => start < a.endIndex && a.startIndex < end) +} + +function collect( + text: string, + regex: RegExp, + kind: SafetyMatchKind, + severity: SafetySeverity, + pattern: string, + accepted: SafetyMatch[], +): void { + regex.lastIndex = 0 + let match: RegExpExecArray | null = regex.exec(text) + while (match !== null) { + const start = match.index + const end = start + match[0].length + if (match[0].length === 0) { + regex.lastIndex += 1 + match = regex.exec(text) + continue + } + if (!overlapsAny(start, end, accepted)) { + accepted.push({ + kind, + severity, + span: match[0], + startIndex: start, + endIndex: end, + pattern, + }) + } + match = regex.exec(text) + } +} + +/** + * Detects PII / contact-exchange / doxxing signals in already-normalized + * text. Total — never throws. Returned indices are positions in the + * obfuscation-folded text (see `foldObfuscations`), not the raw input passed + * by the caller, since folding can change string length (e.g. collapsing + * spaced-out digits); callers that need to highlight the original span should + * treat `span` as the authoritative matched text rather than re-slicing the + * caller's original string by index. + */ +export function detectContactExchange(normalizedText: string): SafetyMatch[] { + const folded = foldObfuscations(normalizedText) + const matches: SafetyMatch[] = [] + + // Contact exchange (sharing a Discord/handle/number) is `orange` = route to + // review, not `red` = hard-block (policy 2026-07-09): in a co-op game this is + // mostly players finding teammates, and the corpus showed it was the single + // biggest source of false-positive friction (0.45% of all traffic). Orange + // still publishes + flags for human audit, and the guard downstream can still + // hard-block genuinely predatory contact (e.g. "how old are you" + a handle). + for (const { kind, regex } of CONTACT_PATTERNS) { + collect(folded, regex, kind, 'orange', regex.source, matches) + } + for (const { pattern, label } of INTENT_PHRASES) { + collect(folded, pattern, 'intent_phrase', 'orange', label, matches) + } + + return matches.sort((a, b) => a.startIndex - b.startIndex) +} diff --git a/apps/moderation/src/service/admission.ts b/apps/moderation/src/service/admission.ts new file mode 100644 index 00000000..74990991 --- /dev/null +++ b/apps/moderation/src/service/admission.ts @@ -0,0 +1,32 @@ +import { consume, newBucket } from '../pipeline/rate-limit.js' +import type { RateLimitConfig, TokenBucket } from '../pipeline/types.js' + +// Admission control for the service as a whole (ADR-7): a GLOBAL ingress +// bucket (separate from per-player chat buckets) so a raid or a misbehaving +// relay can't drive the service into unbounded work. Pure math + one stateful +// wrapper; the future LLM slow lane adds its own queue cap on top of this. + +export const DEFAULT_GLOBAL_INGRESS: RateLimitConfig = { + // Generous vs the measured ~0.3 msg/s average: bursts of 50, sustained 25/s. + burst: 50, + refillPerSec: 25, +} + +export type AdmissionController = { + /** True = admit; false = shed (relay maps this to 429/presets). */ + admit(nowMs: number): boolean +} + +export function createAdmissionController( + config: RateLimitConfig = DEFAULT_GLOBAL_INGRESS, + nowMs = 0, +): AdmissionController { + let bucket: TokenBucket = newBucket(config, nowMs) + return { + admit(now: number): boolean { + const result = consume(bucket, config, now) + bucket = result.bucket + return result.allowed + }, + } +} diff --git a/apps/moderation/src/service/allowlist.test.ts b/apps/moderation/src/service/allowlist.test.ts new file mode 100644 index 00000000..55c41aa7 --- /dev/null +++ b/apps/moderation/src/service/allowlist.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { normalizeForAllowlist } from '../pipeline/normalize.js' +import { parseAllowlist } from './allowlist.js' + +describe('parseAllowlist', () => { + it('parses one normalized entry per line', () => { + const set = parseAllowlist('gg\nglhf\nnice hand\n') + expect(set.has(normalizeForAllowlist('gg') ?? '')).toBe(true) + expect(set.has(normalizeForAllowlist('Nice Hand!') ?? '')).toBe(true) + expect(set.size).toBe(3) + }) + + it('skips comments and blank lines', () => { + const set = parseAllowlist('# header\n\ngg\n \n# trailing\n') + expect(set.size).toBe(1) + }) + + it('normalizes entries with the hot-path function (case + one trailing punct)', () => { + const set = parseAllowlist('Good Game!\n') + // A live message differing only in case/trailing punctuation must hit it. + expect(set.has(normalizeForAllowlist('good game') ?? '')).toBe(true) + expect(set.has(normalizeForAllowlist('GOOD GAME!') ?? '')).toBe(true) + }) + + it('keeps pure-punctuation entries exactly as the hot path would (parity)', () => { + // normalizeForAllowlist deliberately preserves '!!!' — so must the parser, + // or a curated '!!!' entry would never match. + const set = parseAllowlist('!!!\n') + expect(set.has(normalizeForAllowlist('!!!') ?? '')).toBe(true) + }) + + it('dedupes entries that normalize identically', () => { + expect(parseAllowlist('gg\nGG\ngg!\n').size).toBe(1) + }) +}) diff --git a/apps/moderation/src/service/allowlist.ts b/apps/moderation/src/service/allowlist.ts new file mode 100644 index 00000000..a2cb588f --- /dev/null +++ b/apps/moderation/src/service/allowlist.ts @@ -0,0 +1,19 @@ +import { normalizeForAllowlist } from '../pipeline/normalize.js' + +/** + * Parses an allowlist file's text into the Set the moderation service matches + * against (tier-0 fast-pass, see gen-allowlist output). One message per line; + * blank lines and `#` comments are ignored; every entry is normalized with the + * SAME function the hot path uses, so file entries and live messages can never + * disagree about casing/whitespace. Pure — the file read lives in main.ts. + */ +export function parseAllowlist(text: string): Set { + const entries = new Set() + for (const rawLine of text.split('\n')) { + const line = rawLine.trim() + if (line === '' || line.startsWith('#')) continue + const normalized = normalizeForAllowlist(line) + if (normalized !== null) entries.add(normalized) + } + return entries +} diff --git a/apps/moderation/src/service/model-path.test.ts b/apps/moderation/src/service/model-path.test.ts new file mode 100644 index 00000000..ccf1d1f0 --- /dev/null +++ b/apps/moderation/src/service/model-path.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { chooseModelPath } from './model-path.js' + +const DIR = '/model-cache' +const TUNED = `${DIR}/tuned-v2.Q8_0.gguf` + +describe('chooseModelPath', () => { + it('uses the configured path as-is when it is a model file', () => { + expect(chooseModelPath(TUNED, 'file', [`${DIR}/other.gguf`])).toEqual({ + path: TUNED, + }) + }) + + it('returns nothing when GUARD_MODEL is unset', () => { + expect(chooseModelPath(undefined, 'missing', [TUNED])).toEqual({ + path: undefined, + }) + }) + + // The shipped default: GUARD_MODEL is the folder, so the filename is free. + it('loads the only model in the directory GUARD_MODEL points at', () => { + const result = chooseModelPath(DIR, 'directory', [TUNED]) + + expect(result.path).toBe(TUNED) + expect(result.note).toContain('directory') + }) + + // Back-compat: an older config naming a file that is no longer there. + it('falls back to the only model beside a configured path that does not exist', () => { + const result = chooseModelPath(`${DIR}/qwen3guard.gguf`, 'missing', [TUNED]) + + expect(result.path).toBe(TUNED) + expect(result.note).toContain('does not exist') + }) + + it('ignores non-model files when finding the single candidate', () => { + const result = chooseModelPath(DIR, 'directory', [ + `${DIR}/README.md`, + TUNED, + `${DIR}/.gitkeep`, + ]) + + expect(result.path).toBe(TUNED) + }) + + it('matches the extension case-insensitively', () => { + const upper = `${DIR}/Tuned-V2.Q8_0.GGUF` + expect(chooseModelPath(DIR, 'directory', [upper]).path).toBe(upper) + }) + + // Guessing could silently run a model nobody meant to deploy. + it('refuses to guess between multiple models and keeps failing closed', () => { + const result = chooseModelPath(DIR, 'directory', [ + TUNED, + `${DIR}/base.Q8_0.gguf`, + ]) + + expect(result.path).toBe(DIR) + expect(result.note).toContain('refusing to guess') + expect(result.note).toContain('tuned-v2.Q8_0.gguf') + expect(result.note).toContain('base.Q8_0.gguf') + }) + + it('explains an empty or unreadable directory rather than failing silently', () => { + const result = chooseModelPath(DIR, 'directory', []) + + expect(result.path).toBe(DIR) + expect(result.note).toContain('no .gguf found') + }) + + it('always explains itself whenever the configured path was not loaded directly', () => { + for (const candidates of [ + [TUNED], + [TUNED, `${DIR}/b.gguf`], + [] as string[], + ]) { + expect(chooseModelPath(DIR, 'directory', candidates).note).toBeTruthy() + } + }) + + it('returns whatever path shape the caller built, including Windows paths', () => { + const win = 'D:\\models\\tuned-v2.Q8_0.gguf' + expect(chooseModelPath('D:\\models', 'directory', [win]).path).toBe(win) + }) +}) diff --git a/apps/moderation/src/service/model-path.ts b/apps/moderation/src/service/model-path.ts new file mode 100644 index 00000000..0628a120 --- /dev/null +++ b/apps/moderation/src/service/model-path.ts @@ -0,0 +1,70 @@ +// Where the guard model actually is. Pure: the caller does the filesystem +// poking and hands the results in, so every branch below is unit-testable. +// +// Why this exists: the guard is the ONLY model, and no usable guard verdict +// fails closed — so a model the service cannot find is not a degraded mode, +// it is total chat outage. Requiring an exact filename made that a trap (the +// tuned model is not named after the base model it came from), so GUARD_MODEL +// takes a DIRECTORY as well as a file: point it at a folder holding one +// .gguf and the name does not matter. + +export type ConfiguredKind = 'file' | 'directory' | 'missing' + +export type ModelResolution = { + // The path to load, or undefined when GUARD_MODEL is unset. + path: string | undefined + // Operator-facing explanation, or undefined when the configured path was a + // file and got used as-is. Always logged — a fallback must never be + // invisible, and a refusal has to say what to do about it. + note?: string +} + +/** + * @param configured GUARD_MODEL, or undefined when unset. + * @param kind What `configured` points at on disk. + * @param candidatePaths Full paths of the files to choose between: the + * contents of `configured` when it is a directory, otherwise of the + * directory it lives in. Empty when that directory is missing/unreadable. + */ +export function chooseModelPath( + configured: string | undefined, + kind: ConfiguredKind, + candidatePaths: readonly string[], +): ModelResolution { + if (configured === undefined) return { path: undefined } + if (kind === 'file') return { path: configured } + + const models = candidatePaths + .filter((p) => p.toLowerCase().endsWith('.gguf')) + .sort() + + // Exactly one model is unambiguous — whatever it is called, it is the one + // the operator put there. + if (models.length === 1) { + const only = models[0] as string + return { + path: only, + note: + kind === 'directory' + ? `GUARD_MODEL ${configured} is a directory — loading the only .gguf in it: ${only}` + : `GUARD_MODEL ${configured} does not exist; loading the only .gguf beside it instead: ${only}. Set GUARD_MODEL to that path to silence this.`, + } + } + + // Two or more is a real choice, and guessing could load a model nobody + // meant to run. Keep the configured path so this still fails closed, but + // name the candidates — that is the one thing needed to fix it. + if (models.length > 1) { + return { + path: configured, + note: `GUARD_MODEL ${configured} is not a model file and ${models.length} .gguf files are available (${models.join(', ')}) — refusing to guess. Set GUARD_MODEL to one of them.`, + } + } + + return { + path: configured, + // Deliberately does not claim what happens to chat: that depends on + // enforcement mode, and the ENFORCEMENT banner line states it exactly. + note: `no .gguf found at or beside GUARD_MODEL ${configured} — the guard cannot judge anything until a model is in place.`, + } +} diff --git a/apps/moderation/src/service/posture.test.ts b/apps/moderation/src/service/posture.test.ts new file mode 100644 index 00000000..16b31010 --- /dev/null +++ b/apps/moderation/src/service/posture.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { postureBannerLines } from './posture.js' + +describe('postureBannerLines', () => { + it('shadow mode: names both the review-not-block behavior and the carve count', () => { + const lines = postureBannerLines( + { enforcement: 'shadow', authEnabled: true }, + 18, + ) + expect(lines[0]).toContain('ENFORCEMENT=SHADOW') + expect(lines[0]).toContain('PUBLISHED') + expect(lines[0]).toContain('18 profanity phrases') + }) + + it('enforce mode: says blocks are blocked, no carve caveat', () => { + const lines = postureBannerLines( + { enforcement: 'enforce', authEnabled: true }, + 18, + ) + expect(lines[0]).toContain('ENFORCEMENT=ENFORCE') + expect(lines[0]).toContain('BLOCKED') + }) + + it('auth disabled is loud regardless of enforcement mode', () => { + const lines = postureBannerLines( + { enforcement: 'enforce', authEnabled: false }, + 0, + ) + expect(lines[1]).toContain('AUTH=DISABLED') + expect(lines[1]).toContain('UNAUTHENTICATED') + }) + + it('auth enabled is a quiet one-liner', () => { + const lines = postureBannerLines( + { enforcement: 'shadow', authEnabled: true }, + 0, + ) + expect(lines[1]).toBe('[moderation] AUTH=enabled') + }) + + it('always returns exactly one enforcement line and one auth line', () => { + const lines = postureBannerLines( + { enforcement: 'shadow', authEnabled: false }, + 5, + ) + expect(lines).toHaveLength(2) + }) +}) diff --git a/apps/moderation/src/service/posture.ts b/apps/moderation/src/service/posture.ts new file mode 100644 index 00000000..55596ab1 --- /dev/null +++ b/apps/moderation/src/service/posture.ts @@ -0,0 +1,35 @@ +// Pure boot-time posture summary (enforcement + auth). Computed once in +// main.ts from already-gathered config and threaded into BOTH the boot +// banner and GET /health, so the two can never drift and an operator can +// always answer "is this thing actually blocking anything?" from either +// channel. The dangerous state (shadow, auth disabled) must never be the +// quiet one — main.ts prints both banner lines unconditionally, not just the +// enforcing/authenticated branch. + +export type EnforcementMode = 'shadow' | 'enforce' + +export type ServicePosture = { + enforcement: EnforcementMode + authEnabled: boolean +} + +/** + * The exact boot-banner lines for a given posture — pure so the wording is + * unit-tested without booting the process. `carvedWordCount` names how many + * banter-grade profanity words are judged by the (possibly-ignored, in + * shadow mode) guard instead of hard-blocked, since that fact is only scary + * in combination with shadow mode. + */ +export function postureBannerLines( + posture: ServicePosture, + carvedWordCount: number, +): string[] { + return [ + posture.enforcement === 'shadow' + ? `[moderation] ENFORCEMENT=SHADOW — guard 'Unsafe' verdicts are PUBLISHED as band 'review', not blocked; only the deterministic tiers (threat/blocklist/safety) actually reject. ${carvedWordCount} profanity phrases are delegated to that ignored guard verdict instead of a hard block. An unloaded/missing model also publishes as 'review' in this mode (a late one still refuses) — so a model problem here degrades quietly instead of stopping chat; watch model_loaded in /health.` + : "[moderation] ENFORCEMENT=ENFORCE — a guard 'Unsafe' verdict is BLOCKED (band guard_block).", + posture.authEnabled + ? '[moderation] AUTH=enabled' + : '[moderation] AUTH=DISABLED — /moderate accepts UNAUTHENTICATED requests. Set MODERATION_BEARER_TOKEN to at least one non-empty token to require a bearer.', + ] +} diff --git a/apps/moderation/src/service/server.test.ts b/apps/moderation/src/service/server.test.ts new file mode 100644 index 00000000..abbfbe36 --- /dev/null +++ b/apps/moderation/src/service/server.test.ts @@ -0,0 +1,287 @@ +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { GuardEngine } from '../guard/engine.js' +import type { ServicePosture } from './posture.js' +import { createModerationServer } from './server.js' +import { createModerationService } from './service.js' + +/** A guard that answers Safe for everything, instantly. */ +function safeGuard(): GuardEngine { + return { + ready: () => true, + judge: async () => ({ + safety: 'Safe', + categories: [], + latencyMs: 0, + raw: '', + }), + } +} + +const ENFORCE_POSTURE: ServicePosture = { + enforcement: 'enforce', + authEnabled: true, +} + +// HTTP-layer tests against an ephemeral port — auth, validation, verdict +// passthrough, health. This server has exactly two routes. + +const TOKEN = 'test-secret' +let baseUrl = '' +let close: () => void + +beforeAll(async () => { + const service = createModerationService({ guard: safeGuard() }) + const server = createModerationServer({ + service, + bearerTokens: [TOKEN, 'rotation-second-token'], + modelId: 'null-model', + posture: ENFORCE_POSTURE, + lists: { + allowlist: 42, + rewrites: 'unset', + approvedDomains: 0, + }, + }) + await new Promise((resolve) => server.listen(0, resolve)) + const { port } = server.address() as AddressInfo + baseUrl = `http://127.0.0.1:${port}` + close = () => server.close() +}) + +afterAll(() => close()) + +function moderate(body: unknown, token: string | null = TOKEN) { + return fetch(`${baseUrl}/moderate`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }) +} + +const valid = { + playerId: 'p1', + lobbyCode: 'ABCD', + message: 'hello there', +} + +describe('moderation HTTP server', () => { + it('GET /health reports ok when the guard is ready', async () => { + const res = await fetch(`${baseUrl}/health`) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ status: 'ok', model_loaded: true }) + }) + + it('GET /health reports enforcement, auth and list-load state — no shell access needed to know whether this is blocking anything', async () => { + const body = await (await fetch(`${baseUrl}/health`)).json() + expect(body).toMatchObject({ + enforcement: 'enforce', + auth: 'enabled', + model_load_error: null, + lists: { + allowlist: 42, + rewrites: 'unset', + approvedDomains: 0, + }, + guard: { inflight: 0, avg_judge_ms: null }, + }) + }) + + it('GET /health defaults build.git_sha to "unknown" when unset', async () => { + const body = await (await fetch(`${baseUrl}/health`)).json() + expect(body).toMatchObject({ build: { git_sha: 'unknown' } }) + }) + + it('rejects missing/wrong bearer token with 401', async () => { + expect((await moderate(valid, null)).status).toBe(401) + expect((await moderate(valid, 'wrong')).status).toBe(401) + }) + + it('moderates a valid request', async () => { + const res = await moderate(valid) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ verdict: 'allow', band: 'clean' }) + }) + + it('rejects a blocklist message with the verdict body', async () => { + const res = await moderate({ ...valid, message: 'kys' }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ + verdict: 'reject', + band: 'blocklist', + reason: 'blocklist', + }) + }) + + it('400s on malformed body and missing fields', async () => { + const bad = await fetch(`${baseUrl}/moderate`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${TOKEN}`, + }, + body: 'not json', + }) + expect(bad.status).toBe(400) + expect((await moderate({ playerId: 'p1' })).status).toBe(400) + expect((await moderate({ ...valid, message: '' })).status).toBe(400) + }) + + it('413s a payload over the size cap', async () => { + const res = await moderate({ ...valid, message: 'x'.repeat(20_000) }) + expect(res.status).toBe(413) + }) + + it('404s unknown routes, including the removed admin/intake/analyze surface', async () => { + expect((await fetch(`${baseUrl}/nope`)).status).toBe(404) + expect((await fetch(`${baseUrl}/admin/stats`)).status).toBe(404) + expect( + ( + await fetch(`${baseUrl}/analyze`, { + method: 'POST', + headers: { authorization: `Bearer ${TOKEN}` }, + }) + ).status, + ).toBe(404) + expect((await fetch(`${baseUrl}/report`, { method: 'POST' })).status).toBe( + 404, + ) + }) +}) + +describe('moderation HTTP server — model not loaded', () => { + it('still reports the outage on /health in shadow mode, but publishes rather than refusing', async () => { + const service = createModerationService({ + guard: { + ready: () => false, + loadError: () => 'ENOENT: model file missing', + judge: async () => { + throw new Error('not loaded') + }, + }, + }) + const server = createModerationServer({ + service, + modelId: 'x', + posture: { enforcement: 'shadow', authEnabled: false }, + gitSha: 'deadbeef', + }) + await new Promise((resolve) => server.listen(0, resolve)) + const { port } = server.address() as AddressInfo + const url = `http://127.0.0.1:${port}` + try { + const health = await fetch(`${url}/health`) + expect(health.status).toBe(503) + expect(await health.json()).toMatchObject({ + status: 'loading', + model_load_error: 'ENOENT: model file missing', + enforcement: 'shadow', + auth: 'disabled', + build: { git_sha: 'deadbeef' }, + lists: { + allowlist: 'unset', + rewrites: 'unset', + approvedDomains: 'unset', + }, + }) + // /health still says "loading" — monitoring must see the outage. + // But shadow mode grants the guard no enforcement power, so a model + // it cannot consult must not take chat down with it. + const res = await fetch(`${url}/moderate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(valid), + }) + expect(res.status).toBe(200) + const verdict = (await res.json()) as { verdict: string; band: string } + expect(verdict.verdict).toBe('allow') + // 'review' is the band that means "published, but a human should + // see it" — the skip reason itself rides on the logged verdict + // (asserted in decide.test.ts), not on this response contract. + expect(verdict.band).toBe('review') + } finally { + server.close() + } + }) + + it('fails closed with 503 on /moderate when enforcing', async () => { + const service = createModerationService({ + guard: { + ready: () => false, + loadError: () => 'ENOENT: model file missing', + judge: async () => { + throw new Error('not loaded') + }, + }, + }) + const server = createModerationServer({ + service, + modelId: 'x', + posture: { enforcement: 'enforce', authEnabled: false }, + }) + await new Promise((resolve) => server.listen(0, resolve)) + const { port } = server.address() as AddressInfo + try { + const res = await fetch(`http://127.0.0.1:${port}/moderate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(valid), + }) + expect(res.status).toBe(503) + expect(await res.json()).toMatchObject({ error: 'model_not_loaded' }) + } finally { + server.close() + } + }) + + // The whole justification for publishing above is that the tiers ahead of + // the guard still run. If this ever regressed, an unloaded model would mean + // unfiltered chat. + it('still rejects a deterministic block with no model, in shadow mode', async () => { + const service = createModerationService({ + guard: { + ready: () => false, + judge: async () => { + throw new Error('not loaded') + }, + }, + }) + const server = createModerationServer({ + service, + modelId: 'x', + posture: { enforcement: 'shadow', authEnabled: false }, + }) + await new Promise((resolve) => server.listen(0, resolve)) + const { port } = server.address() as AddressInfo + try { + const res = await fetch(`http://127.0.0.1:${port}/moderate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...valid, + message: 'i will kill you and your family', + }), + }) + expect(res.status).toBe(200) + const verdict = (await res.json()) as { + verdict: string + band: string + } + expect(verdict.verdict).toBe('reject') + expect(verdict.band).toBe('threat_block') + } finally { + server.close() + } + }) +}) + +describe('bearer rotation', () => { + it('accepts any configured token during a rotation window', async () => { + const res = await moderate(valid, 'rotation-second-token') + expect(res.status).toBe(200) + }) +}) diff --git a/apps/moderation/src/service/server.ts b/apps/moderation/src/service/server.ts new file mode 100644 index 00000000..d8d43679 --- /dev/null +++ b/apps/moderation/src/service/server.ts @@ -0,0 +1,158 @@ +import { timingSafeEqual } from 'node:crypto' +import { type Server, createServer } from 'node:http' +import type { ServicePosture } from './posture.js' +import type { ModerateRequest, ModerationService } from './service.js' + +// Thin HTTP transport over the service core: POST /moderate + GET /health. +// Nothing else — this service is verdict-only (docs/13: JUDGMENT here, +// STANDING is a separate deployable). Bearer auth on /moderate (shared token +// with the relay, ADR-1); /health open (deliberately: it must answer "is this +// enforcing?" without a bearer token, per its whole purpose). Deliberately +// node:http — two routes don't justify a framework dependency. + +/** A word-list's load state, for /health visibility (see main.ts). */ +export type ListStatus = number | 'unset' | 'error' + +export type ServerOptions = { + service: ModerationService + /** + * Accepted bearer tokens. More than one enables ZERO-DOWNTIME ROTATION: + * add the new token here, let the relay swap at its leisure, then remove + * the old one. Comma-separated via MODERATION_BEARER_TOKEN env. + */ + bearerTokens?: string[] + modelId: string + /** + * Boot-computed enforcement/auth posture — rendered in the boot banner AND + * here, from the same fields, so they can never drift apart. + */ + posture: ServicePosture + /** Word-list load state, surfaced so config drift (DEPLOY.md's known failure mode) is visible by polling instead of log archaeology. */ + lists?: { + allowlist: ListStatus + rewrites: ListStatus + approvedDomains: ListStatus + } + /** The commit this image was built from (Dockerfile's GIT_SHA build arg); 'unknown' outside CI's build. */ + gitSha?: string +} + +function tokenMatches(header: string | undefined, tokens: string[]): boolean { + // Zero configured tokens = accept unauthenticated requests. main.ts is + // responsible for refusing to boot this way in production and for + // warning loudly (boot banner + /health `auth`) everywhere else — nothing + // here enforces "dev only". + if (tokens.length === 0) return true + if (!header?.startsWith('Bearer ')) return false + const presented = Buffer.from(header.slice(7)) + return tokens.some((t) => { + const expected = Buffer.from(t) + return ( + presented.length === expected.length && + timingSafeEqual(presented, expected) + ) + }) +} + +function isModerateRequest(body: unknown): body is ModerateRequest { + if (typeof body !== 'object' || body === null) return false + const b = body as Record + return ( + typeof b.playerId === 'string' && + b.playerId.length > 0 && + typeof b.lobbyCode === 'string' && + typeof b.message === 'string' && + b.message.length > 0 && + b.message.length <= 2000 + ) +} + +export function createModerationServer(opts: ServerOptions): Server { + const { service, modelId, posture } = opts + const tokens = opts.bearerTokens ?? [] + const lists = opts.lists ?? { + allowlist: 'unset' as const, + rewrites: 'unset' as const, + approvedDomains: 'unset' as const, + } + const gitSha = opts.gitSha ?? 'unknown' + + return createServer(async (req, res) => { + const json = (status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } + + try { + if (req.method === 'GET' && req.url === '/health') { + const ready = service.ready() + const diagnostics = service.diagnostics() + return json(ready ? 200 : 503, { + status: ready ? 'ok' : 'loading', + model: modelId, + model_loaded: ready, + model_load_error: diagnostics.modelLoadError, + // The whole point: an operator must be able to tell whether this + // is actually blocking anything from THIS endpoint, no shell needed. + enforcement: posture.enforcement, + auth: posture.authEnabled ? 'enabled' : 'disabled', + build: { git_sha: gitSha }, + lists, + guard: { + inflight: diagnostics.guardInflight, + avg_judge_ms: diagnostics.guardAvgJudgeMs, + }, + }) + } + + if (req.method === 'POST' && req.url === '/moderate') { + if (!tokenMatches(req.headers.authorization, tokens)) { + return json(401, { error: 'unauthorized' }) + } + // Fail-closed: the relay treats 503 per its outage policy. + // + // Not in shadow mode, though. There the guard has no enforcement + // power at all (an 'Unsafe' verdict publishes), so refusing every + // message because it cannot answer would be strictly harsher than + // the case where it did object — and it would make a missing or + // still-loading model indistinguishable from "chat is broken". + // Falling through instead lets the pipeline run: the guard tier + // records itself as skipped ('engine_not_ready') and publishes as + // 'review', while the deterministic tiers ahead of it — threats, + // blocklist, PII/contact, links, rate limit — still reject. + if (!service.ready() && posture.enforcement !== 'shadow') { + return json(503, { error: 'model_not_loaded' }) + } + + let body = '' + for await (const chunk of req) { + body += chunk + if (body.length > 16_384) { + return json(413, { error: 'payload_too_large' }) + } + } + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return json(400, { error: 'invalid_json' }) + } + if (!isModerateRequest(parsed)) { + return json(400, { error: 'invalid_request' }) + } + + const verdict = await service.moderate(parsed) + if (verdict.band === 'shed') { + return json(429, verdict) + } + return json(200, verdict) + } + + return json(404, { error: 'not_found' }) + } catch { + // Totality at the transport edge: an unexpected throw is a 500, and + // the relay's fail-closed handling takes over. + return json(500, { error: 'internal' }) + } + }) +} diff --git a/apps/moderation/src/service/service.test.ts b/apps/moderation/src/service/service.test.ts new file mode 100644 index 00000000..3ce46d3b --- /dev/null +++ b/apps/moderation/src/service/service.test.ts @@ -0,0 +1,480 @@ +import { describe, expect, it } from 'vitest' +import type { GuardEngine } from '../guard/engine.js' +import { DEFAULT_POLICY } from '../pipeline/policy.js' +import { + canMeetDeadline, + createJudgeLane, + createModerationService, +} from './service.js' + +/** A guard that answers Safe for everything, instantly. */ +function safeGuard(): GuardEngine { + return { + ready: () => true, + judge: async () => ({ + safety: 'Safe', + categories: [], + latencyMs: 0, + raw: '', + }), + } +} + +/** Unsafe on 'hurt you', Safe otherwise — the guard-tier stand-in. */ +function threatGuard(): GuardEngine { + return { + ready: () => true, + judge: async (turns) => { + const text = turns[turns.length - 1]?.text ?? '' + const unsafe = text.includes('hurt you') + return { + safety: unsafe ? 'Unsafe' : 'Safe', + categories: unsafe ? ['Violent'] : [], + latencyMs: 0, + raw: '', + } + }, + } +} + +/** A guard that records every text it was asked to judge. */ +function recordingGuard(): { guard: GuardEngine; judged: string[] } { + const judged: string[] = [] + return { + judged, + guard: { + ready: () => true, + judge: async (turns) => { + judged.push(turns[turns.length - 1]?.text ?? '') + return { safety: 'Safe', categories: [], latencyMs: 0, raw: '' } + }, + }, + } +} + +const req = (message: string, playerId = 'p1') => ({ + playerId, + lobbyCode: 'ABCD', + message, +}) + +describe('createJudgeLane', () => { + it('returns the judgement when it lands inside the deadline', async () => { + const lane = createJudgeLane(threatGuard()) + const g = await lane.judge('i will hurt you', 1000) + expect(g).toEqual({ safety: 'Unsafe', categories: ['Violent'] }) + }) + + it('fails closed with skipped:deadline when the judgement is too slow', async () => { + const slow: GuardEngine = { + ready: () => true, + judge: () => new Promise(() => {}), // never resolves + } + const lane = createJudgeLane(slow) + const g = await lane.judge('hello', 10) + expect(g).toEqual({ skipped: 'deadline' }) + expect(lane.depth()).toBe(1) // abandoned judgement still occupies the lane + }) + + it('fails closed when the engine is not loaded', async () => { + const dead: GuardEngine = { + ready: () => false, + judge: async () => { + throw new Error('not loaded') + }, + } + expect(await createJudgeLane(dead).judge('hello', 1000)).toEqual({ + skipped: 'engine_not_ready', + }) + }) + + it('fails closed when the engine throws', async () => { + const broken: GuardEngine = { + ready: () => true, + judge: async () => { + throw new Error('boom') + }, + } + expect(await createJudgeLane(broken).judge('hello', 1000)).toEqual({ + skipped: 'engine_error', + }) + }) + + it('waits indefinitely when deadline is 0', async () => { + const lane = createJudgeLane(safeGuard()) + expect(await lane.judge('hello', 0)).toEqual({ + safety: 'Safe', + categories: [], + }) + }) + + it('avgJudgeMs is null before any judgement lands, then reflects completed ones (for /health)', async () => { + const lane = createJudgeLane(safeGuard()) + expect(lane.avgJudgeMs()).toBeNull() + await lane.judge('hello', 1000) + expect(lane.avgJudgeMs()).not.toBeNull() + }) + + it('short-circuits skipped:backlog once the lane cannot make the deadline', async () => { + // Engine takes ~40ms per judgement; deadline 50ms fits ONE judgement + // but not a queue of them. After the first completes (EMA known), a + // judge call arriving while another occupies the lane must be rejected + // immediately, not after the deadline. + let release: (() => void) | undefined + const engine: GuardEngine = { + ready: () => true, + judge: () => + new Promise((resolve) => { + release = () => + resolve({ safety: 'Safe', categories: [], latencyMs: 0, raw: '' }) + setTimeout(release, 40) + }), + } + const lane = createJudgeLane(engine) + await lane.judge('warm up the EMA', 1000) // avg ≈ 40ms + + const first = lane.judge('occupies the lane', 1000) + const started = performance.now() + const second = await lane.judge('cannot make a 50ms deadline', 50) + const took = performance.now() - started + expect(second).toEqual({ skipped: 'backlog' }) + expect(took).toBeLessThan(25) // immediate, did not wait out the deadline + await first + }) + + it('never backlog-rejects an EMPTY lane — a slow cold start must not lock it out (regression)', async () => { + // One judgement completing slower than the deadline teaches the EMA + // "too slow". Observed live: the lane then rejected every message on an + // idle box forever, because nothing ever ran to correct the EMA. An + // empty lane must always attempt (the deadline race bounds the wait), + // so the EMA can recover as the engine warms up. + let judgeMs = 60 // cold: slower than the 30ms deadline + const engine: GuardEngine = { + ready: () => true, + judge: () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + safety: 'Safe', + categories: [], + latencyMs: 0, + raw: '', + }), + judgeMs, + ), + ), + } + const lane = createJudgeLane(engine) + expect(await lane.judge('cold start', 30)).toEqual({ skipped: 'deadline' }) + await new Promise((r) => setTimeout(r, 60)) // let the abandoned judgement finish (EMA ≈ 60ms) + + // lane idle, EMA pessimistic — must still ATTEMPT, not backlog-reject + judgeMs = 5 // engine has warmed up + expect(await lane.judge('after warmup', 30)).toEqual({ + safety: 'Safe', + categories: [], + }) + }) +}) + +describe('canMeetDeadline', () => { + it('is optimistic before any judgement has completed', () => { + expect(canMeetDeadline(10, null, 100)).toBe(true) + }) + it('always true when the deadline is disabled', () => { + expect(canMeetDeadline(10, 5000, 0)).toBe(true) + }) + it('accounts for the arriving judgement itself', () => { + expect(canMeetDeadline(0, 60, 50)).toBe(false) // own judgement alone misses + expect(canMeetDeadline(0, 40, 50)).toBe(true) + expect(canMeetDeadline(1, 40, 50)).toBe(false) // one ahead in the lane + }) +}) + +describe('createModerationService', () => { + it('allows clean chat', async () => { + const s = createModerationService({ guard: safeGuard() }) + const r = await s.moderate(req('that flush build was crazy')) + expect(r.verdict).toBe('allow') + expect(r.band).toBe('clean') + }) + + it('fast-passes allowlisted presets without judging', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ + guard, + allowlist: new Set(['nice hand']), + }) + const r = await s.moderate(req('Nice Hand!')) + expect(r.band).toBe('preset') + expect(judged).toHaveLength(0) + }) + + it('strips unapproved links before judging AND publishing', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ guard }) + const r = await s.moderate(req('check this https://evil.example/x')) + expect(r.verdict).toBe('allow') + // The stripped form is both what the guard judged and what gets published. + expect(judged[0]).toBe('check this [link removed]') + expect(r.publishText).toBe('check this [link removed]') + }) + + it('keeps approved-domain links intact (no rewrite of the message)', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ + guard, + approvedDomains: ['youtube.com'], + }) + const r = await s.moderate(req('https://www.youtube.com/watch?v=abc')) + expect(r.verdict).toBe('allow') + expect(judged[0]).toBe('https://www.youtube.com/watch?v=abc') + expect(r.publishText).toBeUndefined() + }) + + it('rejects denylist phrases through leetspeak without judging', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ guard }) + const r = await s.moderate(req('k1ll y0urs3lf')) + expect(r.verdict).toBe('reject') + expect(r.band).toBe('blocklist') + expect(judged).toHaveLength(0) + }) + + it('routes contact exchange (phone number) to review, not block', async () => { + const s = createModerationService({ guard: safeGuard() }) + const r = await s.moderate(req('call me 555 123 4567')) + expect(r.verdict).toBe('allow') + expect(r.band).toBe('review') + }) + + it('shadow mode publishes a guard would-block as review', async () => { + const s = createModerationService({ guard: threatGuard() }) + const r = await s.moderate(req('i will hurt you')) + expect(r.verdict).toBe('allow') + expect(r.band).toBe('review') + }) + + it('enforce mode rejects the same message as guard_block', async () => { + const s = createModerationService({ + guard: threatGuard(), + policy: { ...DEFAULT_POLICY, shadowMode: false }, + }) + const r = await s.moderate(req('i will hurt you')) + expect(r.verdict).toBe('reject') + expect(r.band).toBe('guard_block') + }) + + it('a deadline-slow guard fails CLOSED: rejects with guard_unavailable + retry hint', async () => { + const slow: GuardEngine = { + ready: () => true, + judge: () => new Promise(() => {}), + } + const s = createModerationService({ + guard: slow, + policy: { ...DEFAULT_POLICY, shadowMode: false, guardDeadlineMs: 10 }, + }) + const r = await s.moderate(req('some borderline thing')) + expect(r.verdict).toBe('reject') + expect(r.band).toBe('guard_unavailable') + expect(r.reason).toBe('guard_unavailable') + }) + + // The service used to hold a per-player token bucket — the only per-player + // state it had. Rate limiting now lives solely in the relay's chat route + // (same budget, applied earlier), so a burst from one player must reach the + // guard unthrottled rather than being shed by a second, duplicate limiter. + it('keeps no per-player state: a burst from one player is judged, not throttled', async () => { + const clock = () => 1000 // frozen: any surviving bucket could not refill + const { guard, judged } = recordingGuard() + const s = createModerationService({ guard, clock }) + + for (let i = 0; i < 8; i++) { + const r = await s.moderate(req(`msg ${i}`)) + expect(r.band).toBe('clean') + } + + expect(judged).toHaveLength(8) + }) + + it('judging is independent of player history (stateless)', async () => { + const clock = () => 1000 + const g1 = recordingGuard() + const g2 = recordingGuard() + const message = 'hello there' + const r1 = await createModerationService({ + guard: g1.guard, + clock, + }).moderate(req(message)) + const r2 = await createModerationService({ + guard: g2.guard, + clock, + }).moderate(req(message)) + expect(r1.verdict).toBe(r2.verdict) + expect(r1.band).toBe(r2.band) + + // Ten earlier messages from the same player, clock advanced past a full + // refill each time, must not change the eleventh's band. + let t = 1000 + const s = createModerationService({ guard: safeGuard(), clock: () => t }) + for (let i = 0; i < 10; i++) { + t += 2000 // 0.5 tok/s -> full refill between messages + await s.moderate(req(`prior ${i}`)) + } + t += 2000 + const eleventh = await s.moderate(req(message)) + expect(eleventh.band).toBe('clean') + }) + + it('sheds load when global admission is exhausted', async () => { + const s = createModerationService({ + guard: safeGuard(), + admission: { admit: () => false }, + }) + const r = await s.moderate(req('hello')) + expect(r.band).toBe('shed') + expect(r.reason).toBe('service_overloaded') + }) + + it('rejects whitespace-only messages', async () => { + const s = createModerationService({ guard: safeGuard() }) + const r = await s.moderate(req(' ')) + expect(r.verdict).toBe('reject') + expect(r.reason).toBe('empty') + }) + + it('emits a verdict entry for every decision', async () => { + const entries: Record[] = [] + const s = createModerationService({ + guard: safeGuard(), + onVerdict: (e) => entries.push(e), + }) + await s.moderate(req('hello there')) + await s.moderate(req('k1ll y0urs3lf')) + expect(entries).toHaveLength(2) + expect(entries[1]).toMatchObject({ decision: 'reject', band: 'blocklist' }) + // Telemetry must never carry message content — that's the DB's job, and + // stdout must not become a second chat archive. + for (const e of entries) { + expect(e.message).toBeUndefined() + expect(e.publishText).toBeUndefined() + } + }) + + it('records wouldHaveBlocked + guard fields on a shadow would-block — the exact row EVAL.md needs to pull daily', async () => { + const entries: Record[] = [] + const s = createModerationService({ + guard: threatGuard(), + onVerdict: (e) => entries.push(e), + }) + const r = await s.moderate(req('i will hurt you')) + expect(r.band).toBe('review') + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ + band: 'review', + wouldHaveBlocked: true, + guardSafety: 'Unsafe', + guardCategories: ['Violent'], + }) + }) + + it('does not set wouldHaveBlocked on a genuine Controversial review — the two must stay distinguishable', async () => { + const entries: Record[] = [] + const s = createModerationService({ + guard: safeGuard(), + onVerdict: (e) => entries.push(e), + }) + await s.moderate(req('call me 555 123 4567')) // routes to review via PII, not the guard + expect(entries[0]).toMatchObject({ + band: 'review', + wouldHaveBlocked: false, + }) + }) + + it('logs match COUNTS, never the matched words, for blocklist/threat/safety tiers', async () => { + const entries: Record[] = [] + const s = createModerationService({ + guard: safeGuard(), + onVerdict: (e) => entries.push(e), + }) + await s.moderate(req('k1ll y0urs3lf')) + expect(entries[0]).toMatchObject({ obscenityMatchCount: 1 }) + expect(entries[0]).not.toHaveProperty('obscenityMatches') + expect(entries[0]).not.toHaveProperty('threatMatches') + expect(entries[0]).not.toHaveProperty('safetySignals') + }) + + describe('guard input length cap (scoreMaxChars)', () => { + // ~1840 chars, deliberately benign so it reaches the guard tier (no + // obscenity/denylist/PII short-circuit). + const longClean = 'good game everyone that was a really fun match '.repeat( + 40, + ) + + it('caps the judged input to policy.scoreMaxChars on the hot path', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ guard }) // DEFAULT_POLICY: 400 + await s.moderate(req(longClean)) + expect(judged.length).toBeGreaterThan(0) // the guard actually ran + expect(judged.every((t) => t.length <= 400)).toBe(true) + }) + + it('still enforces a denylist phrase sitting past the cap (full-text scan)', async () => { + // The deterministic tiers must see the WHOLE message even though the + // guard only sees the first 400 chars — a slur at char ~675 still blocks. + const filler = 'lorem ipsum dolor sit amet '.repeat(25) // ~675 chars + const s = createModerationService({ guard: safeGuard() }) + const r = await s.moderate(req(`${filler}kill yourself`)) + expect(r.verdict).toBe('reject') + expect(r.band).toBe('blocklist') + }) + + it('scoreMaxChars=0 disables the cap (offline/eval parity)', async () => { + const { guard, judged } = recordingGuard() + const s = createModerationService({ + guard, + policy: { ...DEFAULT_POLICY, scoreMaxChars: 0 }, + }) + await s.moderate(req(longClean)) + expect(judged.some((t) => t.length > 400)).toBe(true) + }) + }) + + describe('verdict-only shape', () => { + it('behaves byte-identically for a clean message', async () => { + const clock = () => 1000 + const s = createModerationService({ guard: safeGuard(), clock }) + const r = await s.moderate(req('hello there')) + expect(r).toEqual({ verdict: 'allow', band: 'clean', latency_ms: 0 }) + }) + }) + + describe('diagnostics (GET /health)', () => { + it('reports the guard load error and null latency before any judgement', () => { + const s = createModerationService({ + guard: { + ready: () => false, + loadError: () => 'ENOENT: model file missing', + judge: async () => { + throw new Error('not loaded') + }, + }, + }) + expect(s.diagnostics()).toEqual({ + modelLoadError: 'ENOENT: model file missing', + guardInflight: 0, + guardAvgJudgeMs: null, + }) + }) + + it('reports null load error and a completed judgement latency once the guard has judged', async () => { + const s = createModerationService({ guard: safeGuard() }) + await s.moderate(req('hello there')) + const d = s.diagnostics() + expect(d.modelLoadError).toBeNull() + expect(d.guardInflight).toBe(0) + expect(d.guardAvgJudgeMs).not.toBeNull() + }) + }) +}) diff --git a/apps/moderation/src/service/service.ts b/apps/moderation/src/service/service.ts new file mode 100644 index 00000000..1c3d1a65 --- /dev/null +++ b/apps/moderation/src/service/service.ts @@ -0,0 +1,310 @@ +import type { GuardEngine } from '../guard/engine.js' +import { createDeterministicAnalyzer } from '../pipeline/analyze.js' +import { decideModeration } from '../pipeline/decide.js' +import { stripLinks } from '../pipeline/links.js' +import { capForScoring, normalizeForAllowlist } from '../pipeline/normalize.js' +import { DEFAULT_POLICY } from '../pipeline/policy.js' +import { type RewriteRule, applyRewrites } from '../pipeline/rewrite.js' +import type { + Decision, + GuardInput, + ModerationPolicy, + TokenBucket, +} from '../pipeline/types.js' +import { + type AdmissionController, + createAdmissionController, +} from './admission.js' + +// The moderation service core: composes the analyzer, the guard engine, +// per-player rate-limit state, and the pure decision core behind one +// `moderate()` call. Qwen3Guard is the ONLY model — its judgement runs INSIDE +// /moderate (real-time gate) under a deadline; a judgement that can't land in +// time fails open on the guard's own answer but CLOSED on the decision (band +// `guard_unavailable`, nothing published). Transport (HTTP) lives in +// server.ts; this module is testable without sockets. +// +// STATELESS AND VERDICT-ONLY: message in, verdict out. There is no database on +// this path, no audit trail, no admin surface — see docs/13 for what else this +// system could be (Standing lives on a separate branch/deployable). The JSONL +// line handed to `onVerdict` is the only record this service produces. + +export type ModerateRequest = { + playerId: string + lobbyCode: string + message: string +} + +export type ModerateResponse = { + verdict: 'allow' | 'reject' + band: Decision['band'] | 'shed' + reason?: string + /** + * Present when the transform tier changed the text — an unapproved link + * stripped to "[link removed]" and/or a community-vocabulary rewrite + * ("cock" → "cocktail"). On allow, the relay MUST publish this instead of the + * original — the transformed form is what was judged. + */ + publishText?: string + latency_ms: number +} + +export type ModerationServiceOptions = { + /** The judge. Real llama.cpp engine in production, fake in tests. */ + guard: GuardEngine + policy?: ModerationPolicy + allowlist?: Set + /** + * Deterministic community-vocabulary rewrites (REWRITES_PATH), applied to + * the message before every other tier. The rewritten text is what gets + * judged and (via `publishText`) published. + */ + rewrites?: readonly RewriteRule[] + /** + * Approved link domains (APPROVED_DOMAINS_PATH). Any URL whose domain is not + * in this set is replaced with a placeholder before every other tier — see + * pipeline/links.ts. Empty (the default) strips ALL links. + */ + approvedDomains?: readonly string[] + admission?: AdmissionController + clock?: () => number + /** Receives every decision for audit (JSONL to stdout in production). */ + onVerdict?: (entry: Record) => void +} + +/** Diagnostic snapshot for GET /health — never read by, or fed back into, a decision. */ +export type ModerationServiceDiagnostics = { + modelLoadError: string | null + guardInflight: number + guardAvgJudgeMs: number | null +} + +export type ModerationService = { + moderate(req: ModerateRequest): Promise + ready(): boolean + diagnostics(): ModerationServiceDiagnostics +} + +/** + * Pure: can a newly-arriving judgement still make its deadline given the + * lane's current occupancy and how long judgements have been taking? Unknown + * speed (no completed judgement yet) is optimistic — the deadline race is the + * backstop either way. + */ +export function canMeetDeadline( + laneDepth: number, + avgJudgeMs: number | null, + deadlineMs: number, +): boolean { + if (!deadlineMs || deadlineMs <= 0) return true + if (avgJudgeMs === null) return true + return (laneDepth + 1) * avgJudgeMs <= deadlineMs +} + +export type JudgeLane = { + /** Judge `text` as plain data — never throws, `{skipped}` on any miss. */ + judge(text: string, deadlineMs: number): Promise + /** Judgements currently occupying the lane (running or abandoned-but-running). */ + depth(): number + /** EMA of completed judgement latency in ms, `null` before the first one lands. */ + avgJudgeMs(): number | null +} + +/** + * Wraps the guard engine in a single stateful "lane": it knows how many + * judgements are in flight and how long they take (EMA), so a message that + * mathematically cannot make its deadline is rejected IMMEDIATELY + * (`{skipped: 'backlog'}`) instead of burning the full deadline first — + * fail-closed overload feels instant, not slow. Deadline hit / engine failure + * / engine not loaded all come back as `{skipped}` too. A judgement that + * outlives its deadline still completes inside the engine (its llama sequence + * stays busy), so it keeps counting toward depth until it truly finishes. + */ +export function createJudgeLane(engine: GuardEngine): JudgeLane { + let inflight = 0 + let avgJudgeMs: number | null = null + const EMA_ALPHA = 0.3 + + return { + depth: () => inflight, + avgJudgeMs: () => avgJudgeMs, + + async judge(text: string, deadlineMs: number): Promise { + if (!engine.ready()) return { skipped: 'engine_not_ready' } + // Backlog short-circuit ONLY when queued behind someone. An empty lane + // always attempts: the deadline race bounds the wait regardless, and + // each completed judgement refreshes the EMA — without this, one slow + // cold-start judgement teaches the EMA "too slow" and the lane locks + // itself out forever (observed live: idle lane, instant rejects). + if (inflight > 0 && !canMeetDeadline(inflight, avgJudgeMs, deadlineMs)) { + return { skipped: 'backlog' } + } + + inflight++ + const started = performance.now() + const judgement = engine + .judge([{ who: 'sender', text }]) + .then((j): GuardInput => { + const took = performance.now() - started + avgJudgeMs = + avgJudgeMs === null + ? took + : avgJudgeMs + EMA_ALPHA * (took - avgJudgeMs) + return { safety: j.safety, categories: j.categories } + }) + .catch((): GuardInput => ({ skipped: 'engine_error' })) + .finally(() => { + inflight-- + }) + if (!deadlineMs || deadlineMs <= 0) return judgement + + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve({ skipped: 'deadline' }), deadlineMs) + }) + const result = await Promise.race([judgement, deadline]) + clearTimeout(timer) + return result + }, + } +} + +export function createModerationService( + opts: ModerationServiceOptions, +): ModerationService { + const policy = opts.policy ?? DEFAULT_POLICY + const allowlist = opts.allowlist ?? new Set() + const admission = opts.admission ?? createAdmissionController() + const clock = opts.clock ?? (() => Date.now()) + const onVerdict = opts.onVerdict ?? (() => {}) + const analyzer = createDeterministicAnalyzer({ + approvedDomains: opts.approvedDomains ?? [], + }) + const lane = createJudgeLane(opts.guard) + + // No per-player state at all: nothing about a player is remembered between + // requests, so two instances judge the same message identically and a + // restart changes nothing. Per-player rate limiting lives in the relay's + // chat route (same budget, applied earlier); this service protects itself + // with the global ingress valve and the guard lane's backlog shedding. + + /** The capped text the guard actually reads (deterministic tiers saw it all). */ + const guardText = (message: string): string => + capForScoring([message], policy.scoreMaxChars)[0] ?? message + + const rewrites = opts.rewrites ?? [] + const approvedDomains = opts.approvedDomains ?? [] + // Deterministic pre-judge transform: strip unapproved links, then apply + // community rewrites. Links go first so an approved URL is never mangled by + // a word rewrite. The result is what every tier judges AND what is published. + const transform = (raw: string): string => + applyRewrites(stripLinks(raw, approvedDomains), rewrites) + + return { + ready: () => opts.guard.ready(), + diagnostics: () => ({ + modelLoadError: opts.guard.loadError?.() ?? null, + guardInflight: lane.depth(), + guardAvgJudgeMs: lane.avgJudgeMs(), + }), + + async moderate(req: ModerateRequest): Promise { + const started = clock() + + if (!admission.admit(started)) { + return { + verdict: 'reject', + band: 'shed', + reason: 'service_overloaded', + latency_ms: clock() - started, + } + } + + // Transform tier first: strip unapproved links, then community-vocabulary + // rewrites. The transformed text is what every tier judges and what gets + // published (publishText). + const text = transform(req.message) + + const normalized = normalizeForAllowlist(text) + if (normalized === null) { + return { + verdict: 'reject', + band: 'clean', + reason: 'empty', + latency_ms: clock() - started, + } + } + + const evidence = analyzer.analyze(text) + const isAllowlisted = allowlist.has(normalized) + + // Skip the guard when a cheaper tier already decides the outcome. When + // we do judge, cap the input length (the deterministic scan above + // already saw the FULL message — only the LLM's cost is bounded here). + const deterministicOutcome = + isAllowlisted || + evidence.threatMatches.length > 0 || + evidence.obscenityMatches.length > 0 || + evidence.safetySignals.some((s) => s.severity === 'red') + + // An explicit skip marker, never a bare null: decideModeration's + // fail-closed branch keys off `{skipped}`, so anything other than a + // real verdict rejects instead of silently allowing. + const guard: GuardInput = deterministicOutcome + ? { skipped: 'deterministic' } + : await lane.judge(guardText(text), policy.guardDeadlineMs) + + const decision = decideModeration({ + message: text, + nowMs: started, + isAllowlisted, + threatMatches: evidence.threatMatches, + obscenityMatches: evidence.obscenityMatches, + safetySignals: evidence.safetySignals, + guard, + policy, + }) + + const latency_ms = clock() - started + const v = decision.verdict + // Operational telemetry only — NO message content, and no matched + // WORDS either (obscenity/threat matches carry the literal matched + // text; only their counts are safe to log). `rewritten` flags that a + // rewrite happened without logging either form. `wouldHaveBlocked` + + // the guard fields are the whole point of shadow mode: without them a + // shadow would-block and a genuine Controversial review are + // indistinguishable in this stream, and EVAL.md's daily + // wouldHaveBlocked pull has nothing to pull from. + onVerdict({ + ts: new Date(started).toISOString(), + playerId: req.playerId, + lobbyCode: req.lobbyCode, + decision: decision.decision, + band: decision.band, + rewritten: text !== req.message, + allowlisted: v.allowlisted, + wouldHaveBlocked: v.wouldHaveBlocked ?? false, + guardSafety: v.guardSafety, + guardCategories: v.guardCategories, + guardSkipped: v.guardSkipped, + threatMatchCount: v.threatMatches?.length ?? 0, + obscenityMatchCount: v.obscenityMatches?.length ?? 0, + safetySignalCount: v.safetySignals?.length ?? 0, + latency_ms, + }) + + const response: ModerateResponse = { + verdict: decision.decision, + band: decision.band, + latency_ms, + } + if (text !== req.message) { + response.publishText = text + } + if (decision.decision === 'reject') { + response.reason = decision.band + } + return response + }, + } +} diff --git a/apps/moderation/tsconfig.build.json b/apps/moderation/tsconfig.build.json new file mode 100644 index 00000000..39d34b1a --- /dev/null +++ b/apps/moderation/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "types": ["node"], + "moduleDetection": "auto", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/apps/moderation/tsconfig.json b/apps/moderation/tsconfig.json new file mode 100644 index 00000000..71aef334 --- /dev/null +++ b/apps/moderation/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "moduleDetection": "auto", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/moderation/vitest.config.ts b/apps/moderation/vitest.config.ts new file mode 100644 index 00000000..66c6767a --- /dev/null +++ b/apps/moderation/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + exclude: ['**/node_modules/**', '**/dist/**'], + }, +}) diff --git a/apps/server/.env.example b/apps/server/.env.example index 252ab06c..998403f2 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -41,4 +41,12 @@ BET_MOD_INDEX_URL=https://raw.githubusercontent.com/Balatro-Multiplayer/BETModIn # BalatroMultiplayerServerInternal package (overlaid onto packages/internal at # deploy time, see that repo's README), not by this repo directly. Listed # here for deploy convenience only. -LAUNCHER_INTEGRITY_SECRET=change-me-use-at-least-32-random-chars \ No newline at end of file +LAUNCHER_INTEGRITY_SECRET=change-me-use-at-least-32-random-chars +# Chat moderation bridge (optional). Leave MODERATION_SERVICE_URL unset to disable +# the bridge entirely — chat falls back to the local obscenity filter. +MODERATION_SERVICE_URL= +MODERATION_BEARER_TOKEN= +# Must exceed the moderation service's own judgement deadline plus margin, or a +# slow-but-successful verdict is abandoned here while it still occupies the +# service's single model lane. +MODERATION_TIMEOUT_MS=6000 diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 65b9bd8c..db3a27d3 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -16,6 +16,15 @@ function optionalBool(key: string, defaultValue: boolean): boolean { return value === 'true' || value === '1' } +// Guards '' -> 0 and non-numeric input -> NaN, either of which would make +// every request abort immediately if used as a timeout unchecked. +function optionalPositiveInt(key: string, defaultValue: number): number { + const value = process.env[key] + if (!value) return defaultValue + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue +} + const NODE_ENV = optional('NODE_ENV', 'development') const IS_PRODUCTION = NODE_ENV === 'production' @@ -72,4 +81,17 @@ export const env = { // failing when unset, matching the "missing optional integration disables // the feature, not the server" pattern used elsewhere in this file. BET_MOD_INDEX_URL: optional('BET_MOD_INDEX_URL', ''), + // Chat moderation bridge. Unset (default) means dormant — chat keeps using the + // local obscenity filter, unchanged. Set MODERATION_SERVICE_URL to route chat + // through an external moderation service instead. + MODERATION_SERVICE_URL: optional('MODERATION_SERVICE_URL', '').replace( + /\/+$/, + '', + ), + MODERATION_BEARER_TOKEN: optional('MODERATION_BEARER_TOKEN', ''), + // Must exceed the moderation service's own judgement deadline plus margin. + // Set below it and a slow-but-successful verdict is abandoned here while it + // still occupies the service's single model lane, so the player sees an + // outage and their retry deepens the backlog that caused it. + MODERATION_TIMEOUT_MS: optionalPositiveInt('MODERATION_TIMEOUT_MS', 6000), } as const diff --git a/apps/server/src/features/chat/chat.service.ts b/apps/server/src/features/chat/chat.service.ts index fb3d01a5..5db6cc92 100644 --- a/apps/server/src/features/chat/chat.service.ts +++ b/apps/server/src/features/chat/chat.service.ts @@ -1,14 +1,33 @@ -import { insertReportedLobbyMessage } from '../../infrastructure/gateways/chat.gateway.js' +import { + insertFlaggedMessage, + insertReportedLobbyMessage, +} from '../../infrastructure/gateways/chat.gateway.js' import { logChat } from '../../infrastructure/gateways/history.gateway.js' +import { + callModerationService, + isModerationBridgeEnabled, +} from '../../infrastructure/gateways/moderation.gateway.js' import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js' import { getConfig } from '../../state/config.js' import type { Lobby } from '../../state/lobby.js' +import { decideModerationOutcome } from './moderation.js' import { normalizeForAllowlist } from './normalization.js' import { moderateMessage } from './obscenity.js' +export type ChatBlockReason = + | 'empty' + | 'moderated' + | 'unavailable' + +export type ChatResult = + | { ok: true; publishText?: string } + | { ok: false; reason: ChatBlockReason } + function isAllowlisted(message: string): boolean { const key = normalizeForAllowlist(message) if (key === null) return false + // Curation invariant (link-free, rewrite-neutral) documented at the load + // site: infrastructure/gateways/config.gateway.ts. return getConfig().chatAllowlist.has(key) } @@ -18,16 +37,56 @@ export async function processAndPublishMessage( displayName: string, message: string, steamIdHash: string | null = null, -): Promise<{ ok: boolean; reason?: string }> { +): Promise { const normalized = normalizeForAllowlist(message) if (normalized === null) { return { ok: false, reason: 'empty' } } + // textToPublish may be rewritten by the moderation service; message (the + // original typed text) always goes to the evidence buffer/report DB — a + // rewrite must never launder what the player actually typed. + let textToPublish = message + if (!isAllowlisted(message)) { - const result = await moderateMessage(message, playerId) - if (!result.allowed) { - return { ok: false, reason: 'moderated' } + // Dormant when MODERATION_SERVICE_URL is unset — chat keeps using the + // local obscenity filter, unchanged. + if (isModerationBridgeEnabled()) { + // No displayName: the service has no use for it, and a name is + // needless identity to hand a component that only judges text. + const attempt = await callModerationService({ + playerId, + lobbyCode: lobby.code, + message, + }) + const outcome = decideModerationOutcome(attempt) + if (!outcome.allowed) { + // The remote service logs no message content by design, and this + // branch bypasses the local obscenity filter's own evidence write + // below — without this, a remotely-blocked message would exist + // nowhere at all. Never blocking on it: a DB hiccup here must not + // turn a moderation block into a 500. + if (outcome.reason === 'moderated') { + try { + await insertFlaggedMessage(playerId, message, { + source: 'remote', + band: outcome.band ?? 'unknown', + }) + } catch (err) { + console.error( + '[moderation] failed to record a remotely-blocked message as evidence:', + err, + ) + } + } + return { ok: false, reason: outcome.reason } + } + textToPublish = outcome.publishText ?? message + } else { + const result = await moderateMessage(message, playerId) + if (!result.allowed) { + return { ok: false, reason: 'moderated' } + } } } @@ -35,7 +94,7 @@ export async function processAndPublishMessage( lobby.code, playerId, displayName, - message, + textToPublish, ) const sentAt = new Date() @@ -54,5 +113,10 @@ export async function processAndPublishMessage( }) } + // Only when a rewrite happened: the sender's client shows what other + // players actually received, so a rewrite is never silent. + if (textToPublish !== message) { + return { ok: true, publishText: textToPublish } + } return { ok: true } } diff --git a/apps/server/src/features/chat/moderation.ts b/apps/server/src/features/chat/moderation.ts new file mode 100644 index 00000000..62bcf05e --- /dev/null +++ b/apps/server/src/features/chat/moderation.ts @@ -0,0 +1,80 @@ +// Pure decision core for the remote moderation bridge. No I/O — the shell +// (infrastructure/gateways/moderation.gateway.ts) performs the HTTP call and +// hands the outcome in as plain data. + +export type ModerationAttempt = { status: number; body: unknown } | null + +export type ModerationBlockReason = 'moderated' | 'unavailable' + +export type ModerationOutcome = + | { allowed: true; publishText: string | null } + | { allowed: false; reason: ModerationBlockReason; band?: string } + +type ModerationResponseBody = { + verdict: 'allow' | 'reject' + band?: unknown + publishText?: unknown +} + +function isModerationResponseBody( + value: unknown, +): value is ModerationResponseBody { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return record.verdict === 'allow' || record.verdict === 'reject' +} + +// Must match the relay's own cap on an incoming message (lobby.route.ts) — +// the transform tier can rewrite a compliant message into one that exceeds it. +const MAX_PUBLISH_LENGTH = 500 + +// Any transport failure, non-200 status, unparseable body, or unrecognised +// verdict fails closed as 'unavailable' — never allow on uncertainty. That +// deliberately includes HTTP 429: the service sheds load globally with that +// status, which is a capacity problem, not this player sending too fast. +// A malformed band or publishText degrades the single field that's +// unreadable rather than failing the whole message: an unrecognisable band +// on a reject is still a block (just the generic one), and an unreadable +// publishText on an allow is treated as a rewrite to nothing — see below. +export function decideModerationOutcome( + attempt: ModerationAttempt, +): ModerationOutcome { + if ( + attempt === null || + attempt.status !== 200 || + !isModerationResponseBody(attempt.body) + ) { + return { allowed: false, reason: 'unavailable' } + } + + const { verdict, band, publishText } = attempt.body + + if (verdict === 'allow') { + if (publishText === undefined) return { allowed: true, publishText: null } + // A rewrite was intended. A non-string or blank-after-trim rewrite is + // unreadable or nothing, and publishing the original in that case would + // republish exactly the content the rewrite was meant to remove — block + // instead of falling back to the original. + if (typeof publishText !== 'string' || !publishText.trim()) { + return { allowed: false, reason: 'moderated', band: 'unusable_rewrite' } + } + // A rewrite that exceeds the relay's own message cap can't be published + // or echoed to the sender as-is. Truncating risks handing back a broken + // sentence or a fragment the service never actually returned — verifying + // a truncated cut would need another round trip, so this blocks instead + // of guessing at a safe cut point. + if (publishText.length > MAX_PUBLISH_LENGTH) { + return { allowed: false, reason: 'moderated', band: 'oversized_rewrite' } + } + return { allowed: true, publishText } + } + + const bandName = typeof band === 'string' ? band : undefined + + // guard_unavailable is the service rejecting because its own model was + // unavailable, not because the message was bad — the player must not be + // told they broke a rule. + if (bandName === 'guard_unavailable') + return { allowed: false, reason: 'unavailable' } + return { allowed: false, reason: 'moderated', band: bandName } +} diff --git a/apps/server/src/features/lobby/lobby.route.ts b/apps/server/src/features/lobby/lobby.route.ts index efb645b5..a1242417 100644 --- a/apps/server/src/features/lobby/lobby.route.ts +++ b/apps/server/src/features/lobby/lobby.route.ts @@ -248,10 +248,15 @@ export function createLobbyRouter(service: LobbyService): Router { throw new AppError('Message cannot be empty', 400) if (result.reason === 'moderated') throw new AppError('Message was rejected by moderation', 403) + if (result.reason === 'unavailable') + throw new AppError( + 'Chat moderation is unavailable. Try again in a moment.', + 503, + ) throw new AppError('Failed to send message', 500) } - res.json({ ok: true }) + res.json(result.publishText ? { ok: true, publishText: result.publishText } : { ok: true }) } catch (err) { next(err) } diff --git a/apps/server/src/infrastructure/gateways/chat.gateway.ts b/apps/server/src/infrastructure/gateways/chat.gateway.ts index 6d1b10f3..0bd4d2d4 100644 --- a/apps/server/src/infrastructure/gateways/chat.gateway.ts +++ b/apps/server/src/infrastructure/gateways/chat.gateway.ts @@ -7,10 +7,15 @@ type MatchRecord = { endIndex: number } +// `matches` is an untyped jsonb column. The local obscenity filter records +// which words matched; the remote moderation bridge has no word list to +// report, only the band the service rejected on. +export type FlaggedMatches = MatchRecord[] | { source: 'remote'; band: string } + export async function insertFlaggedMessage( playerId: string, message: string, - matches: MatchRecord[], + matches: FlaggedMatches, ): Promise { const threeMonths = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) await db.insert(flaggedMessages).values({ diff --git a/apps/server/src/infrastructure/gateways/moderation.gateway.ts b/apps/server/src/infrastructure/gateways/moderation.gateway.ts new file mode 100644 index 00000000..187e3929 --- /dev/null +++ b/apps/server/src/infrastructure/gateways/moderation.gateway.ts @@ -0,0 +1,72 @@ +import { env } from '../../env.js' +import type { ModerationAttempt } from '../../features/chat/moderation.js' + +export type ModerationRequest = { + playerId: string + lobbyCode: string + message: string +} + +export type ModerationServiceConfig = { + url: string + bearerToken: string + timeoutMs: number +} + +// Whether the bridge is on is one seam, not two: this reads the same env +// value used to build the default call config below, so "is it enabled" and +// "what do we call" can never disagree. +export function isModerationBridgeEnabled(): boolean { + return env.MODERATION_SERVICE_URL !== '' +} + +// Evaluated fresh per call (default params re-run their expression each time +// the arg is omitted) rather than snapshotted at module load, so it can never +// go stale relative to isModerationBridgeEnabled() above. +function currentConfig(): ModerationServiceConfig { + return { + url: env.MODERATION_SERVICE_URL, + bearerToken: env.MODERATION_BEARER_TOKEN, + timeoutMs: env.MODERATION_TIMEOUT_MS, + } +} + +// Single attempt, no retries. Any network error, abort, or non-JSON body +// comes back as null — the caller treats that as a failed-closed attempt. +export async function callModerationService( + request: ModerationRequest, + config: ModerationServiceConfig = currentConfig(), +): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (config.bearerToken) { + headers.Authorization = `Bearer ${config.bearerToken}` + } + + try { + const res = await fetch(`${config.url}/moderate`, { + method: 'POST', + headers, + body: JSON.stringify(request), + // Never follow a redirect for a "verdict" response — that would let + // something other than the configured origin decide whether a message + // is allowed. Treat a redirect as the transport failure it is. + redirect: 'error', + signal: AbortSignal.timeout(config.timeoutMs), + }) + const body: unknown = await res.json().catch(() => null) + if (res.status !== 200 && res.status !== 429) { + // 401/413/500/503 all fail closed downstream; without this line a + // misconfigured token or a dead service is a silent chat outage. + console.error( + `[moderation] moderation service returned ${res.status}, chat fails closed`, + ) + } + return { status: res.status, body } + } catch (err) { + console.error( + '[moderation] request to moderation service failed, chat fails closed:', + err, + ) + return null + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b0d7723f..50d56905 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -189,6 +189,14 @@ async function start() { server = app.listen(env.PORT, () => { console.log(`[server] API server listening on port ${env.PORT}`) + // Whether chat is being moderated is the first thing you want to + // know from a log, and a typo'd URL otherwise presents as chat + // silently failing closed with no clue why. + console.log( + env.MODERATION_SERVICE_URL + ? `[server] chat moderation ON (${env.MODERATION_SERVICE_URL})` + : '[server] chat moderation OFF — using the local obscenity filter', + ) }) process.on('SIGTERM', shutdown) diff --git a/apps/server/src/tests/routes/chat.moderation.test.ts b/apps/server/src/tests/routes/chat.moderation.test.ts new file mode 100644 index 00000000..55099db1 --- /dev/null +++ b/apps/server/src/tests/routes/chat.moderation.test.ts @@ -0,0 +1,126 @@ +import request from 'supertest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { signJwt } from '../../features/auth/jwt.js' +import type { ChatResult } from '../../features/chat/chat.service.js' +import { processAndPublishMessage } from '../../features/chat/chat.service.js' +import { setConfig } from '../../state/config.js' +import { createSession } from '../../state/index.js' +import { createTestApp } from './app.js' + +// The route's job is the reason -> status -> copy mapping; the decision +// matrix behind each reason (local obscenity filter vs. the moderation +// bridge, band handling, ...) is covered where it's made, in +// moderation.test.ts and chat.service.test.ts. +vi.mock('../../features/chat/chat.service.js', () => ({ + processAndPublishMessage: vi.fn(), +})) + +const mockProcessAndPublishMessage = vi.mocked(processAndPublishMessage) + +const app = createTestApp() + +function authHeader(playerId: string, steamName: string, lobbyCode?: string) { + createSession(steamName, { id: playerId, chatEnabled: true }) + const token = signJwt({ playerId, steamName, lobbyCode }) + return `Bearer ${token}` +} + +async function createLobby(hostId: string, hostName: string) { + const res = await request(app) + .post('/api/lobbies') + .set('Authorization', authHeader(hostId, hostName)) + .send({ modId: 'mod1' }) + return res.body.lobby.code as string +} + +describe('POST /api/lobbies/:code/chat', () => { + beforeEach(() => { + setConfig({ + tosVersion: 0, + mods: [], + chatAllowlist: new Set(), + chatEnabled: true, + }) + }) + + // 'moderated' fires identically whether processAndPublishMessage reached + // it via the dormant local obscenity filter (MODERATION_SERVICE_URL unset, + // the default) or the remote bridge — the route maps on `reason`, not on + // which path produced it, so this one case stands in for both. Every + // string here is the pre-existing/legacy copy and must not change: an + // upgrade to the bridge must not alter what a dormant-config deployment + // already sends. + it.each([ + ['empty', 400, 'Message cannot be empty'], + ['moderated', 403, 'Message was rejected by moderation'], + [ + 'unavailable', + 503, + 'Chat moderation is unavailable. Try again in a moment.', + ], + ] as const)( + 'maps reason %s to status %i with the exact player-facing string', + async (reason, status, message) => { + const code = await createLobby('host1', 'Alice') + mockProcessAndPublishMessage.mockResolvedValue({ ok: false, reason }) + + const res = await request(app) + .post(`/api/lobbies/${code}/chat`) + .set('Authorization', authHeader('host1', 'Alice', code)) + .send({ message: 'hello' }) + + expect(res.status).toBe(status) + expect(res.body).toEqual({ error: message }) + }, + ) + + it('falls back to a 500 with the generic copy for an unrecognised reason', async () => { + const code = await createLobby('host1', 'Alice') + // Outside the typed ChatBlockReason union on purpose — this exercises + // the defensive fallback a caller can only reach by disagreeing with the + // type checker (e.g. a stale build of chat.service.js). + mockProcessAndPublishMessage.mockResolvedValue({ + ok: false, + reason: 'something_else', + } as unknown as ChatResult) + + const res = await request(app) + .post(`/api/lobbies/${code}/chat`) + .set('Authorization', authHeader('host1', 'Alice', code)) + .send({ message: 'hello' }) + + expect(res.status).toBe(500) + expect(res.body).toEqual({ + error: 'Failed to send message', + }) + }) + + it('returns { ok: true } with no publishText when nothing was rewritten', async () => { + const code = await createLobby('host1', 'Alice') + mockProcessAndPublishMessage.mockResolvedValue({ ok: true }) + + const res = await request(app) + .post(`/api/lobbies/${code}/chat`) + .set('Authorization', authHeader('host1', 'Alice', code)) + .send({ message: 'hello there' }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true }) + }) + + it('returns { ok: true, publishText } when the message was rewritten', async () => { + const code = await createLobby('host1', 'Alice') + mockProcessAndPublishMessage.mockResolvedValue({ + ok: true, + publishText: '**** you', + }) + + const res = await request(app) + .post(`/api/lobbies/${code}/chat`) + .set('Authorization', authHeader('host1', 'Alice', code)) + .send({ message: 'not repeating that here' }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, publishText: '**** you' }) + }) +}) diff --git a/apps/server/src/tests/services/chat.service.test.ts b/apps/server/src/tests/services/chat.service.test.ts new file mode 100644 index 00000000..14b13e01 --- /dev/null +++ b/apps/server/src/tests/services/chat.service.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { processAndPublishMessage } from '../../features/chat/chat.service.js' +import type { ModerationAttempt } from '../../features/chat/moderation.js' +import { normalizeForAllowlist } from '../../features/chat/normalization.js' +import { moderateMessage } from '../../features/chat/obscenity.js' +import { db } from '../../infrastructure/db/index.js' +import { + callModerationService, + isModerationBridgeEnabled, +} from '../../infrastructure/gateways/moderation.gateway.js' +import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js' +import { setConfig } from '../../state/config.js' +import { Lobby } from '../../state/lobby.js' + +vi.mock('../../features/chat/obscenity.js', () => ({ + moderateMessage: vi.fn(), +})) + +vi.mock('../../infrastructure/gateways/moderation.gateway.js', () => ({ + callModerationService: vi.fn(), + isModerationBridgeEnabled: vi.fn(), +})) + +const mockModerateMessage = vi.mocked(moderateMessage) +const mockCallModerationService = vi.mocked(callModerationService) +const mockIsModerationBridgeEnabled = vi.mocked(isModerationBridgeEnabled) + +function makeLobby(): Lobby { + return new Lobby('ABC123', 'mod1', 'host1') +} + +describe('chat.service.processAndPublishMessage', () => { + beforeEach(() => { + mockModerateMessage.mockResolvedValue({ allowed: true }) + mockIsModerationBridgeEnabled.mockReturnValue(false) + }) + + describe('dormant (MODERATION_SERVICE_URL unset, the default)', () => { + it('runs the legacy local obscenity path unchanged and publishes on allow', async () => { + const lobby = makeLobby() + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'hello there', + ) + + expect(result).toEqual({ ok: true }) + expect(mockModerateMessage).toHaveBeenCalledWith('hello there', 'p1') + expect(mockCallModerationService).not.toHaveBeenCalled() + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'ABC123', + 'p1', + 'Alice', + 'hello there', + ) + }) + + it('blocks with reason moderated when the local obscenity filter rejects it', async () => { + mockModerateMessage.mockResolvedValue({ allowed: false }) + const lobby = makeLobby() + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'bad word', + ) + + expect(result).toEqual({ ok: false, reason: 'moderated' }) + expect(mqttService.publishChatMessage).not.toHaveBeenCalled() + }) + }) + + describe('empty / allowlisted messages short-circuit regardless of moderation config', () => { + it('returns reason empty for a message that normalizes to nothing', async () => { + const lobby = makeLobby() + const result = await processAndPublishMessage(lobby, 'p1', 'Alice', ' ') + expect(result).toEqual({ ok: false, reason: 'empty' }) + }) + + it('publishes an allowlisted message without calling either moderation path', async () => { + const key = normalizeForAllowlist('gg') + setConfig({ + tosVersion: 0, + mods: [], + chatAllowlist: new Set([key as string]), + }) + const lobby = makeLobby() + + const result = await processAndPublishMessage(lobby, 'p1', 'Alice', 'gg') + + expect(result).toEqual({ ok: true }) + expect(mockModerateMessage).not.toHaveBeenCalled() + expect(mockCallModerationService).not.toHaveBeenCalled() + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'ABC123', + 'p1', + 'Alice', + 'gg', + ) + }) + }) + + // The full allow/reject/band decision matrix (moderated, unavailable, + // unknown bands, ...) is asserted once, against + // the pure core, in moderation.test.ts. These tests cover only what's + // specific to this wiring: which path runs, and what gets published vs. + // kept in evidence. + describe('configured (MODERATION_SERVICE_URL set)', () => { + function mockAttempt(attempt: ModerationAttempt) { + mockCallModerationService.mockResolvedValue(attempt) + } + + beforeEach(() => { + mockIsModerationBridgeEnabled.mockReturnValue(true) + }) + + it('publishes the original text on allow', async () => { + mockAttempt({ status: 200, body: { verdict: 'allow' } }) + const lobby = makeLobby() + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'hello there', + ) + + expect(result).toEqual({ ok: true }) + // No displayName: the service has no use for a player's name. + expect(mockCallModerationService).toHaveBeenCalledWith({ + playerId: 'p1', + lobbyCode: 'ABC123', + message: 'hello there', + }) + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'ABC123', + 'p1', + 'Alice', + 'hello there', + ) + }) + + it('publishes the rewrite from publishText, but keeps the original in the evidence buffer and report DB', async () => { + mockAttempt({ + status: 200, + body: { verdict: 'allow', publishText: '**** you' }, + }) + const lobby = makeLobby() + lobby.isReported = true + const valuesMock = vi.fn().mockResolvedValue(undefined) + vi.mocked(db.insert).mockReturnValueOnce({ values: valuesMock } as never) + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'fuck you', + ) + + // The sender is told what was actually delivered, so the client can + // show "sent as ..." instead of the rewrite being silent. + expect(result).toEqual({ ok: true, publishText: '**** you' }) + // MQTT gets the rewritten text... + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'ABC123', + 'p1', + 'Alice', + '**** you', + ) + // ...but the in-memory evidence buffer keeps the original typed text + expect(lobby.messageBuffer.at(-1)?.message).toBe('fuck you') + // ...and so does the reported-lobby DB row + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'fuck you' }), + ) + }) + + it('fails closed as unavailable on a transport failure, and never publishes', async () => { + mockAttempt(null) + const lobby = makeLobby() + + const result = await processAndPublishMessage(lobby, 'p1', 'Alice', 'hi') + + expect(result).toEqual({ ok: false, reason: 'unavailable' }) + expect(mqttService.publishChatMessage).not.toHaveBeenCalled() + }) + + it('never calls the local obscenity filter once configured', async () => { + mockAttempt({ status: 200, body: { verdict: 'allow' } }) + const lobby = makeLobby() + + await processAndPublishMessage(lobby, 'p1', 'Alice', 'hi') + + expect(mockModerateMessage).not.toHaveBeenCalled() + }) + + it('allowlisted messages never reach the remote service either', async () => { + const key = normalizeForAllowlist('gg') + setConfig({ + tosVersion: 0, + mods: [], + chatAllowlist: new Set([key as string]), + }) + const lobby = makeLobby() + + const result = await processAndPublishMessage(lobby, 'p1', 'Alice', 'gg') + + expect(result).toEqual({ ok: true }) + expect(mockCallModerationService).not.toHaveBeenCalled() + }) + + it("blocks rather than publishing or echoing a rewrite over the relay's own 500-char cap", async () => { + mockAttempt({ + status: 200, + body: { verdict: 'allow', publishText: 'x'.repeat(501) }, + }) + const lobby = makeLobby() + + const result = await processAndPublishMessage(lobby, 'p1', 'Alice', 'hi') + + expect(result).toEqual({ ok: false, reason: 'moderated' }) + expect(mqttService.publishChatMessage).not.toHaveBeenCalled() + }) + + // The remote service logs no message content by design, so this is the + // only place a remotely-blocked message is preserved as evidence. + describe('evidence for a remote block', () => { + it('records a moderated block with the band and the original typed text', async () => { + mockAttempt({ + status: 200, + body: { verdict: 'reject', band: 'threat_block' }, + }) + const lobby = makeLobby() + const valuesMock = vi.fn().mockResolvedValue(undefined) + vi.mocked(db.insert).mockReturnValueOnce({ + values: valuesMock, + } as never) + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'bad message', + ) + + expect(result).toEqual({ ok: false, reason: 'moderated' }) + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + playerId: 'p1', + message: 'bad message', + matches: { source: 'remote', band: 'threat_block' }, + }), + ) + }) + + it('does not write evidence for an unavailable block', async () => { + mockAttempt(null) + const lobby = makeLobby() + + await processAndPublishMessage(lobby, 'p1', 'Alice', 'hi') + + expect(db.insert).not.toHaveBeenCalled() + }) + + it('still blocks the message when the evidence write itself fails', async () => { + mockAttempt({ + status: 200, + body: { verdict: 'reject', band: 'threat_block' }, + }) + const lobby = makeLobby() + vi.mocked(db.insert).mockImplementationOnce(() => { + throw new Error('db unavailable') + }) + + const result = await processAndPublishMessage( + lobby, + 'p1', + 'Alice', + 'bad message', + ) + + expect(result).toEqual({ ok: false, reason: 'moderated' }) + }) + }) + }) +}) diff --git a/apps/server/src/tests/services/moderation.gateway.test.ts b/apps/server/src/tests/services/moderation.gateway.test.ts new file mode 100644 index 00000000..01dd6a48 --- /dev/null +++ b/apps/server/src/tests/services/moderation.gateway.test.ts @@ -0,0 +1,214 @@ +import http from 'node:http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { env } from '../../env.js' +import { decideModerationOutcome } from '../../features/chat/moderation.js' +import { + type ModerationServiceConfig, + callModerationService, + isModerationBridgeEnabled, +} from '../../infrastructure/gateways/moderation.gateway.js' + +const config: ModerationServiceConfig = { + url: 'http://moderation.local', + bearerToken: '', + timeoutMs: 1500, +} + +const request = { + playerId: 'p1', + lobbyCode: 'ABC123', + message: 'hello there', +} + +describe('moderation.gateway.callModerationService', () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('posts to /moderate with the request body', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ verdict: 'allow' }), { status: 200 }), + ) + + await callModerationService(request, config) + + expect(global.fetch).toHaveBeenCalledWith( + 'http://moderation.local/moderate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(request), + }), + ) + }) + + it('omits the Authorization header when no bearer token is configured', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ verdict: 'allow' }), { status: 200 }), + ) + + await callModerationService(request, config) + + const [, init] = vi.mocked(global.fetch).mock.calls[0] + const headers = init?.headers as Record + expect(headers.Authorization).toBeUndefined() + }) + + it('sends an Authorization: Bearer header when a token is configured', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ verdict: 'allow' }), { status: 200 }), + ) + + await callModerationService(request, { + ...config, + bearerToken: 'secret-token', + }) + + const [, init] = vi.mocked(global.fetch).mock.calls[0] + const headers = init?.headers as Record + expect(headers.Authorization).toBe('Bearer secret-token') + }) + + it('returns the parsed status and body on a normal response', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ verdict: 'reject', band: 'threat_block' }), + { status: 200 }, + ), + ) + + await expect(callModerationService(request, config)).resolves.toEqual({ + status: 200, + body: { verdict: 'reject', band: 'threat_block' }, + }) + }) + + it('passes through a non-2xx status with its body', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ error: 'overloaded' }), { status: 429 }), + ) + + await expect(callModerationService(request, config)).resolves.toEqual({ + status: 429, + body: { error: 'overloaded' }, + }) + }) + + it('returns a null body when the response is not valid JSON (e.g. a proxy error page)', async () => { + vi.mocked(global.fetch).mockResolvedValue( + new Response('502 Bad Gateway', { status: 200 }), + ) + + await expect(callModerationService(request, config)).resolves.toEqual({ + status: 200, + body: null, + }) + }) + + it('returns null (transport failure) when fetch rejects, e.g. on timeout/abort', async () => { + vi.mocked(global.fetch).mockRejectedValue( + new DOMException('The operation was aborted', 'AbortError'), + ) + + await expect(callModerationService(request, config)).resolves.toBeNull() + }) + + it('returns null when fetch throws a network error', async () => { + vi.mocked(global.fetch).mockRejectedValue(new TypeError('fetch failed')) + + await expect(callModerationService(request, config)).resolves.toBeNull() + }) + + // Trust-boundary regression: a redirect must never be able to hand back an + // "allow" from somewhere other than the configured moderation origin. This + // drives a real HTTP 302 through the real fetch (restored for just this + // test) instead of asserting on call args, so it actually catches a + // regression back to the default redirect: 'follow' behaviour. + it('fails closed (never allows) when the moderation origin responds with a redirect', async () => { + global.fetch = originalFetch + + // The redirect target is a REACHABLE origin serving a valid allow + // verdict. That is what makes this a real regression test: revert to + // redirect: 'follow' and fetch reaches this server, returns 200 + + // allow, and the assertion below fails. Pointing at an unresolvable + // host would pass either way. + const impostor = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ verdict: 'allow' })) + }) + await new Promise((resolve) => impostor.listen(0, resolve)) + const impostorAddress = impostor.address() + if (impostorAddress === null || typeof impostorAddress === 'string') { + throw new Error('failed to bind impostor server') + } + + const redirector = http.createServer((_req, res) => { + res.writeHead(302, { + Location: `http://127.0.0.1:${impostorAddress.port}/moderate`, + }) + res.end() + }) + await new Promise((resolve) => redirector.listen(0, resolve)) + const address = redirector.address() + if (address === null || typeof address === 'string') { + throw new Error('failed to bind test redirect server') + } + + try { + const result = await callModerationService(request, { + ...config, + url: `http://127.0.0.1:${address.port}`, + }) + + expect(result).toBeNull() + expect(decideModerationOutcome(result).allowed).toBe(false) + } finally { + redirector.close() + impostor.close() + } + }) +}) + +describe('moderation.gateway.isModerationBridgeEnabled', () => { + // env.ts's readonly typing is TS-only (no runtime freeze). + const mutableEnv = env as { MODERATION_SERVICE_URL: string } + const originalUrl = env.MODERATION_SERVICE_URL + + afterEach(() => { + mutableEnv.MODERATION_SERVICE_URL = originalUrl + }) + + it('is false when MODERATION_SERVICE_URL is unset', () => { + mutableEnv.MODERATION_SERVICE_URL = '' + expect(isModerationBridgeEnabled()).toBe(false) + }) + + it('is true when MODERATION_SERVICE_URL is set, and the default call config agrees', async () => { + mutableEnv.MODERATION_SERVICE_URL = 'http://moderation.local' + expect(isModerationBridgeEnabled()).toBe(true) + + // The default config is re-read live, from the same value, so a caller + // that only checks isModerationBridgeEnabled() can never end up calling + // out to a stale/empty URL. + const originalFetch = global.fetch + global.fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ verdict: 'allow' }), { status: 200 }), + ) + try { + await callModerationService(request) + expect(global.fetch).toHaveBeenCalledWith( + 'http://moderation.local/moderate', + expect.anything(), + ) + } finally { + global.fetch = originalFetch + } + }) +}) diff --git a/apps/server/src/tests/services/moderation.test.ts b/apps/server/src/tests/services/moderation.test.ts new file mode 100644 index 00000000..54367cba --- /dev/null +++ b/apps/server/src/tests/services/moderation.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { decideModerationOutcome } from '../../features/chat/moderation.js' + +describe('moderation.decideModerationOutcome', () => { + it('allows and publishes the original text when no rewrite is given', () => { + expect( + decideModerationOutcome({ status: 200, body: { verdict: 'allow' } }), + ).toEqual({ + allowed: true, + publishText: null, + }) + }) + + it('allows and returns the rewrite when publishText is a non-empty string', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: 'cleaned up text' }, + }), + ).toEqual({ allowed: true, publishText: 'cleaned up text' }) + }) + + it('blocks rather than republishing the original when the rewrite is empty', () => { + // A present-but-empty publishText means the service redacted the message + // down to nothing and still allowed it. Falling back to the original + // text here would republish exactly what the rewrite removed. + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: '' }, + }), + ).toEqual({ allowed: false, reason: 'moderated', band: 'unusable_rewrite' }) + }) + + it('blocks rather than republishing the original when the rewrite is whitespace-only', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: ' ' }, + }), + ).toEqual({ allowed: false, reason: 'moderated', band: 'unusable_rewrite' }) + }) + + it('blocks rather than republishing the original when publishText has the wrong type', () => { + // A rewrite was intended and is unreadable — that is closer to "reject" + // than to "service is down", so this degrades to a plain block rather + // than 'unavailable'. + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: 42 }, + }), + ).toEqual({ allowed: false, reason: 'moderated', band: 'unusable_rewrite' }) + }) + + // The chat route has its own rate limiter in front of this, so a + // rate_limited band is not surfaced separately - it degrades to the + // generic block like any other band the relay does not special-case. + it('degrades the rate_limited band to a generic block', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'reject', band: 'rate_limited' }, + }), + ).toMatchObject({ allowed: false, reason: 'moderated' }) + }) + + // HTTP 429 is the service shedding load globally. Reporting it as + // rate_limited would tell a player who sent one message that they are + // chatting too fast, because someone else flooded the service. + it('treats an HTTP 429 as a service outage, not as the player being too fast', () => { + expect( + decideModerationOutcome({ status: 429, body: { verdict: 'allow' } }), + ).toEqual({ allowed: false, reason: 'unavailable' }) + expect(decideModerationOutcome({ status: 429, body: null })).toEqual({ + allowed: false, + reason: 'unavailable', + }) + expect( + decideModerationOutcome({ + status: 429, + body: 'too many requests', + }), + ).toEqual({ allowed: false, reason: 'unavailable' }) + }) + + it.each(['threat_block', 'blocklist', 'safety_block', 'guard_block'])( + 'blocks with the generic reason for the %s band', + (band) => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'reject', band }, + }), + ).toEqual({ + allowed: false, + reason: 'moderated', + band, + }) + }, + ) + + it('reports guard_unavailable as unavailable, not as a rule violation', () => { + // The service rejected because its own model was down. Telling the + // player they broke a rule would be a lie. + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'reject', band: 'guard_unavailable' }, + }), + ).toEqual({ allowed: false, reason: 'unavailable' }) + }) + + it('degrades an unrecognised reject band to the generic block, never an allow', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'reject', band: 'some_future_band' }, + }), + ).toEqual({ allowed: false, reason: 'moderated', band: 'some_future_band' }) + }) + + it('blocks with reason moderated when a reject has no band at all', () => { + expect( + decideModerationOutcome({ status: 200, body: { verdict: 'reject' } }), + ).toEqual({ + allowed: false, + reason: 'moderated', + }) + }) + + it('fails closed as unavailable on a transport failure (null attempt)', () => { + expect(decideModerationOutcome(null)).toEqual({ + allowed: false, + reason: 'unavailable', + }) + }) + + it.each([400, 401, 413, 500, 503])( + 'fails closed as unavailable for HTTP status %i', + (status) => { + expect( + decideModerationOutcome({ status, body: { verdict: 'allow' } }), + ).toEqual({ + allowed: false, + reason: 'unavailable', + }) + }, + ) + + it('fails closed as unavailable on an unparseable body', () => { + expect(decideModerationOutcome({ status: 200, body: null })).toEqual({ + allowed: false, + reason: 'unavailable', + }) + expect( + decideModerationOutcome({ + status: 200, + body: 'proxy error', + }), + ).toEqual({ + allowed: false, + reason: 'unavailable', + }) + expect(decideModerationOutcome({ status: 200, body: [] })).toEqual({ + allowed: false, + reason: 'unavailable', + }) + }) + + it('fails closed as unavailable on an unrecognised verdict value', () => { + expect( + decideModerationOutcome({ status: 200, body: { verdict: 'maybe' } }), + ).toEqual({ + allowed: false, + reason: 'unavailable', + }) + }) + + // A malformed band or publishText is a cosmetic contract drift, not + // evidence the service is unreachable. A bad band on a reject can't cause + // an unsafe publish (the message is blocked either way), so it degrades to + // a plain block instead of taking chat down; see the publishText-type-drift + // cases above for the allow side. + it('degrades a reject with a wrong-typed band to the generic block, not unavailable', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'reject', band: 42 }, + }), + ).toEqual({ allowed: false, reason: 'moderated' }) + }) + + describe("the relay's own message cap (500 chars)", () => { + it('blocks a rewrite that exceeds the cap rather than publishing or truncating it', () => { + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: 'x'.repeat(501) }, + }), + ).toEqual({ allowed: false, reason: 'moderated', band: 'oversized_rewrite' }) + }) + + it('allows a rewrite exactly at the cap', () => { + const text = 'x'.repeat(500) + expect( + decideModerationOutcome({ + status: 200, + body: { verdict: 'allow', publishText: text }, + }), + ).toEqual({ allowed: true, publishText: text }) + }) + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index b53d05f7..e93ebad1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,11 +57,37 @@ services: EMQX_BROKER_URL: mqtt://emqx:1883 EMQX_API_URL: http://emqx:18083/api/v5 WEB_BASE_URL: ${WEB_BASE_URL:-https://new.balatromp.com} + # Chat is moderated by default here so the stack works out of the box. + # This needs a model in the moderation volume: without one the service + # reports not-ready and chat fails closed rather than going through + # unmoderated. To turn the bridge off instead, run with + # MODERATION_SERVICE_URL= docker compose up + # and chat falls back to the local obscenity filter. + MODERATION_SERVICE_URL: ${MODERATION_SERVICE_URL-http://moderation:8001} depends_on: emqx: condition: service_healthy postgres: condition: service_healthy + moderation: + condition: service_started + restart: unless-stopped + networks: + - bmp + + moderation: + build: + context: . + dockerfile: apps/moderation/Dockerfile + container_name: bmp-moderation + # No host port: only the api talks to it, over the compose network. + environment: + # Logs what it WOULD block without blocking. Set 0 to enforce. + SHADOW_MODE: ${SHADOW_MODE:-1} + volumes: + # The GGUF is not in the image. Drop it here and the service finds it at + # the path baked into the Dockerfile. + - moderation-model-cache:/model-cache restart: unless-stopped networks: - bmp @@ -90,3 +116,4 @@ volumes: emqx-data: emqx-log: pg-data: + moderation-model-cache: diff --git a/package.json b/package.json index 223e75bf..e390b2d9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,6 @@ "turbo": "^2.0.0" }, "pnpm": { - "onlyBuiltDependencies": ["@biomejs/biome", "esbuild"] + "onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "node-llama-cpp"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f59c76..1c4fda5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,34 @@ importers: specifier: ^2.0.0 version: 2.9.14 + apps/moderation: + dependencies: + node-llama-cpp: + specifier: ^3.19.0 + version: 3.19.1(typescript@5.9.3) + obscenity: + specifier: ^0.4.6 + version: 0.4.6 + devDependencies: + '@biomejs/biome': + specifier: '*' + version: 1.9.4 + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + fast-check: + specifier: ^4.8.0 + version: 4.9.0 + tsx: + specifier: ^4.19.0 + version: 4.22.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + apps/server: dependencies: '@bmp/types': @@ -1185,6 +1213,10 @@ packages: tailwindcss: optional: true + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + '@icons-pack/react-simple-icons@13.13.0': resolution: {integrity: sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==} engines: {node: '>=24', pnpm: '>=10'} @@ -1344,6 +1376,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1363,6 +1399,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -1425,6 +1467,97 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@node-llama-cpp/linux-arm64@3.19.1': + resolution: {integrity: sha512-lDfmsN2ChkfM9vcglYoJ8jiaQACTF/bMgdO/owkzhNLdFkIFI6eAqSFaBsCsYq53BspN/JTssle7QOOI+nDx3A==} + engines: {node: '>=20.0.0'} + cpu: [arm64, x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-armv7l@3.19.1': + resolution: {integrity: sha512-7z15VVqb9vjnidUxVDlkOlSmBCsVsH+5cAYzOCXJm97XiQE9julGeAtWh/H/3D2Mkt/ABy2V8rMsGA5FwP3y0A==} + engines: {node: '>=20.0.0'} + cpu: [arm, x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-riscv64@3.19.1': + resolution: {integrity: sha512-FUQe5ur6k9d2/2TLoz42+66wHVZed4kUNBUZVqqv6jq2pmFClMHOtgkTZOKGMaUvr+XAQwQkS8y2oTjpbJJ6fg==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-x64-cuda-ext@3.19.1': + resolution: {integrity: sha512-7xi2XMB0HBvFRYjfMMLjv6Su2roA3EZl9siJpxim716Oume017Lk1hF/72Mn0XnKfQWd2Z9vfstFTvEYTfZ0WQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-x64-cuda@3.19.1': + resolution: {integrity: sha512-jm6+tBVvIbNLaajVAAzoUWvoMOoKa/P0rUpwX9UAJJJthM3gigy+UshG7fVMtt+ExFapy5MPSWDNKvjwWIt67Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-x64-vulkan@3.19.1': + resolution: {integrity: sha512-VcNq3bKEbOkUernV6HFSmD4WrxL37rTdumPlMcVnQFvzC9q+P3gLKxRCLeqpJ26wU/iMHqzlz1/fOMwn9FWjDw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/linux-x64@3.19.1': + resolution: {integrity: sha512-ntnV8GLeuuGwp5eS5aCxmF0oo4OjjDo6LTWJGJhuieLF6SwMlfbqxCYh3yo1lcepeNf2uCHnXMydyCVnqLWJcQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-llama-cpp/mac-arm64-metal@3.19.1': + resolution: {integrity: sha512-M4ignq2Hhru35/zPrTAxUsuHOK96Hk7xeY1Oj9+Gty6XQ4dEmVUPwEYpB9ra3D0vTxQaMgFt4pj8L+gTR5u9fg==} + engines: {node: '>=20.0.0'} + cpu: [arm64, x64] + os: [darwin] + + '@node-llama-cpp/mac-x64@3.19.1': + resolution: {integrity: sha512-wDv1cuxDopj3ZF3fCCJtcn++ypb8h6QIH8yFevijxeMCaFqSzWm1o/uLkiXiEE2pbb9tq5uCD1x4Ss+iJvCs9w==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@node-llama-cpp/win-arm64@3.19.1': + resolution: {integrity: sha512-mmzC7bydEn/D0IJXMJ1GT/WSu48u/oIkwMPvo1G51JI/QoG1mRsdx0dBFvPVfraIveSvLCvV09ZbGpm2SdgMMg==} + engines: {node: '>=20.0.0'} + cpu: [arm64, x64] + os: [win32] + + '@node-llama-cpp/win-x64-cuda-ext@3.19.1': + resolution: {integrity: sha512-6WDpsUkkLbYbfipAgb3UiFqze21DYLefkU/hpnXcMXK6SwkfmMl5VTbbi4giXBWWD+Rxw/7lu8bUQoCRO2XuvQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64-cuda@3.19.1': + resolution: {integrity: sha512-uDeiuXvj871az+QfxjRNb2/frUiH3KnW6J2f73AL5mQoZuunyt4i/Bq25RjfE6kS8aZopBwCjRfzN/P+KxPC/g==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64-vulkan@3.19.1': + resolution: {integrity: sha512-yFk9sk6Eph8Kmxsp/r7lzerpAX0j3xPKHOfetjIWxSQr81fvs8RHpOqAfc+YjbTI9iQe989LDV0A1kNuq8MnDw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@node-llama-cpp/win-x64@3.19.1': + resolution: {integrity: sha512-BpWFyyj0om2fFLoA3JpANepw0vdQiaalvc4pooH5NN2N7i9yutTEgXpn2s+qVpryM+NCT6WrfV/kN/oRfCowNw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + '@orama/orama@3.1.18': resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} @@ -1941,6 +2074,62 @@ packages: react-redux: optional: true + '@reflink/reflink-darwin-arm64@0.1.19': + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@reflink/reflink-darwin-x64@0.1.19': + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-arm64-musl@0.1.19': + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@reflink/reflink-linux-x64-gnu@0.1.19': + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-x64-musl@0.1.19': + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@reflink/reflink-win32-x64-msvc@0.1.19': + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@reflink/reflink@0.1.19': + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} + engines: {node: '>= 10'} + '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] @@ -2110,6 +2299,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -2242,6 +2437,10 @@ packages: '@tanstack/virtual-core@3.17.1': resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + '@tinyhttp/content-disposition@2.2.4': + resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} + engines: {node: '>=12.17.0'} + '@turbo/darwin-64@2.9.14': resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] @@ -2460,6 +2659,26 @@ packages: resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} engines: {node: '>=12.0'} + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2478,6 +2697,9 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2537,6 +2759,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2553,23 +2779,62 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chmodrp@1.0.2: + resolution: {integrity: sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmake-js@8.0.0: + resolution: {integrity: sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2577,6 +2842,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2616,6 +2885,10 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2685,6 +2958,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2816,6 +3093,12 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2832,6 +3115,10 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + env-var@7.5.0: + resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==} + engines: {node: '>=10'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2890,6 +3177,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -2953,6 +3244,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -2969,6 +3264,14 @@ packages: picomatch: optional: true + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filenamify@6.0.0: + resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} + engines: {node: '>=16'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -3003,6 +3306,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3123,6 +3430,14 @@ packages: engines: {node: '>= 18.0.0'} hasBin: true + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3204,6 +3519,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} @@ -3213,6 +3532,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3228,6 +3550,11 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipull@3.9.5: + resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==} + engines: {node: '>=18.0.0'} + hasBin: true + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3237,9 +3564,21 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3247,10 +3586,21 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3265,6 +3615,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -3275,6 +3628,12 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + lifecycle-utils@2.1.0: + resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==} + + lifecycle-utils@3.1.1: + resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3373,12 +3732,20 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowdb@7.0.1: + resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} + engines: {node: '>=18'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3587,9 +3954,21 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + motion-dom@12.40.0: resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} @@ -3626,6 +4005,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3657,6 +4041,23 @@ packages: sass: optional: true + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + engines: {node: ^18 || ^20 || >= 21} + + node-api-headers@1.9.0: + resolution: {integrity: sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==} + + node-llama-cpp@3.19.1: + resolution: {integrity: sha512-i3yq1IHSg+ugdl78/noPeYvtMFIMBaW10nWl0KIXoES9P7HCnrKT3yhpO4p08bib7YJ1boFdnibg8znOILzpCA==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + number-allocator@1.0.14: resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} @@ -3696,15 +4097,31 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + engines: {node: '>=20'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@3.0.0: + resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==} + engines: {node: '>=12'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -3712,6 +4129,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -3787,6 +4208,18 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + pretty-ms@8.0.0: + resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} + engines: {node: '>=14.16'} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -3794,6 +4227,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -3801,6 +4237,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -3813,6 +4252,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3952,12 +4395,28 @@ packages: resolution: {integrity: sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw==} engines: {node: '>=18.0.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -4002,6 +4461,14 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shell-quote@1.8.4: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} @@ -4029,6 +4496,27 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} + + sleep-promise@9.1.0: + resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -4075,12 +4563,48 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + + stdout-update@4.0.1: + resolution: {integrity: sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==} + engines: {node: '>=16.0.0'} + + steno@4.0.2: + resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -4121,6 +4645,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + terser@5.48.0: resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} @@ -4231,10 +4759,17 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4269,6 +4804,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4401,11 +4940,21 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + which@4.0.0: resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -4423,6 +4972,10 @@ packages: worker-timers@8.0.31: resolution: {integrity: sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4442,11 +4995,31 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4944,6 +5517,8 @@ snapshots: '@tailwindcss/oxide': 4.3.1 tailwindcss: 4.3.1 + '@huggingface/jinja@0.5.9': {} + '@icons-pack/react-simple-icons@13.13.0(react@19.2.7)': dependencies: react: 19.2.7 @@ -5045,6 +5620,10 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5070,6 +5649,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -5128,6 +5715,48 @@ snapshots: '@noble/hashes@1.8.0': {} + '@node-llama-cpp/linux-arm64@3.19.1': + optional: true + + '@node-llama-cpp/linux-armv7l@3.19.1': + optional: true + + '@node-llama-cpp/linux-riscv64@3.19.1': + optional: true + + '@node-llama-cpp/linux-x64-cuda-ext@3.19.1': + optional: true + + '@node-llama-cpp/linux-x64-cuda@3.19.1': + optional: true + + '@node-llama-cpp/linux-x64-vulkan@3.19.1': + optional: true + + '@node-llama-cpp/linux-x64@3.19.1': + optional: true + + '@node-llama-cpp/mac-arm64-metal@3.19.1': + optional: true + + '@node-llama-cpp/mac-x64@3.19.1': + optional: true + + '@node-llama-cpp/win-arm64@3.19.1': + optional: true + + '@node-llama-cpp/win-x64-cuda-ext@3.19.1': + optional: true + + '@node-llama-cpp/win-x64-cuda@3.19.1': + optional: true + + '@node-llama-cpp/win-x64-vulkan@3.19.1': + optional: true + + '@node-llama-cpp/win-x64@3.19.1': + optional: true + '@orama/orama@3.1.18': {} '@paralleldrive/cuid2@2.3.1': @@ -5668,6 +6297,42 @@ snapshots: react: 19.2.7 react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) + '@reflink/reflink-darwin-arm64@0.1.19': + optional: true + + '@reflink/reflink-darwin-x64@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-musl@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-musl@0.1.19': + optional: true + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + optional: true + + '@reflink/reflink-win32-x64-msvc@0.1.19': + optional: true + + '@reflink/reflink@0.1.19': + optionalDependencies: + '@reflink/reflink-darwin-arm64': 0.1.19 + '@reflink/reflink-darwin-x64': 0.1.19 + '@reflink/reflink-linux-arm64-gnu': 0.1.19 + '@reflink/reflink-linux-arm64-musl': 0.1.19 + '@reflink/reflink-linux-x64-gnu': 0.1.19 + '@reflink/reflink-linux-x64-musl': 0.1.19 + '@reflink/reflink-win32-arm64-msvc': 0.1.19 + '@reflink/reflink-win32-x64-msvc': 0.1.19 + optional: true + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -5783,6 +6448,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -5885,6 +6556,8 @@ snapshots: '@tanstack/virtual-core@3.17.1': {} + '@tinyhttp/content-disposition@2.2.4': {} + '@turbo/darwin-64@2.9.14': optional: true @@ -5983,7 +6656,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 22.19.19 + '@types/node': 25.9.4 '@types/mdast@4.0.4': dependencies: @@ -6005,7 +6678,7 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 22.19.19 + '@types/node': 25.9.4 pg-protocol: 1.14.0 pg-types: 2.2.0 @@ -6117,6 +6790,18 @@ snapshots: adm-zip@0.5.18: {} + ansi-escapes@6.2.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -6129,6 +6814,10 @@ snapshots: astring@1.9.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + asynckit@0.4.0: {} bail@2.0.2: {} @@ -6200,6 +6889,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -6210,26 +6901,68 @@ snapshots: check-error@2.1.3: {} + chmodrp@1.0.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + + ci-info@4.4.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-spinners@3.4.0: {} + client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} + cmake-js@8.0.0: + dependencies: + debug: 4.4.3 + fs-extra: 11.4.0 + node-api-headers: 1.9.0 + rc: 1.2.8 + semver: 7.8.1 + tar: 7.5.22 + url-join: 4.0.1 + which: 6.0.1 + yargs: 17.7.3 + transitivePeerDependencies: + - supports-color + collapse-white-space@2.1.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@2.20.3: optional: true @@ -6258,6 +6991,12 @@ snapshots: cookiejar@2.1.4: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} d3-array@3.2.4: @@ -6312,6 +7051,8 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -6360,6 +7101,10 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} enhanced-resolve@5.21.6: @@ -6371,6 +7116,8 @@ snapshots: env-paths@3.0.0: {} + env-var@7.5.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -6550,6 +7297,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} + escape-html@1.0.3: {} escape-string-regexp@5.0.0: {} @@ -6641,6 +7390,10 @@ snapshots: extend@3.0.2: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-safe-stringify@2.1.1: {} fast-unique-numbers@9.0.27: @@ -6652,6 +7405,12 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + filename-reserved-regex@3.0.0: {} + + filenamify@6.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -6690,6 +7449,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true @@ -6806,6 +7571,10 @@ snapshots: transitivePeerDependencies: - supports-color + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6972,12 +7741,16 @@ snapshots: ieee754@1.2.1: {} + ignore@7.0.6: {} + immer@10.2.0: {} immer@11.1.8: {} inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} internmap@2.0.3: {} @@ -6986,6 +7759,30 @@ snapshots: ipaddr.js@1.9.1: {} + ipull@3.9.5: + dependencies: + '@tinyhttp/content-disposition': 2.2.4 + async-retry: 1.3.3 + chalk: 5.6.2 + ci-info: 4.4.0 + cli-spinners: 2.9.2 + commander: 10.0.1 + eventemitter3: 5.0.4 + filenamify: 6.0.0 + fs-extra: 11.4.0 + is-unicode-supported: 2.1.0 + lifecycle-utils: 2.1.0 + lodash.debounce: 4.0.8 + lowdb: 7.0.1 + pretty-bytes: 6.1.1 + pretty-ms: 8.0.0 + sleep-promise: 9.1.0 + slice-ansi: 7.1.2 + stdout-update: 4.0.1 + strip-ansi: 7.2.0 + optionalDependencies: + '@reflink/reflink': 0.1.19 + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -6995,14 +7792,28 @@ snapshots: is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-hexadecimal@2.0.1: {} + is-interactive@2.0.0: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + isexe@3.1.5: {} + isexe@4.0.0: {} + jiti@2.7.0: {} js-sdsl@4.3.0: {} @@ -7013,6 +7824,12 @@ snapshots: dependencies: argparse: 2.0.1 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -7037,6 +7854,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + lifecycle-utils@2.1.0: {} + + lifecycle-utils@3.1.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -7102,10 +7923,19 @@ snapshots: lodash.once@4.1.1: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 + longest-streak@3.1.0: {} loupe@3.2.1: {} + lowdb@7.0.1: + dependencies: + steno: 4.0.2 + lru-cache@10.4.3: {} lucide-react@1.21.0(react@19.2.7): @@ -7569,8 +8399,16 @@ snapshots: mime@2.6.0: {} + mimic-function@5.0.1: {} + minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + motion-dom@12.40.0: dependencies: motion-utils: 12.39.0 @@ -7620,6 +8458,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.16: {} + negotiator@1.0.0: {} next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -7651,6 +8491,59 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@8.9.1: {} + + node-api-headers@1.9.0: {} + + node-llama-cpp@3.19.1(typescript@5.9.3): + dependencies: + '@huggingface/jinja': 0.5.9 + async-retry: 1.3.3 + bytes: 3.1.2 + chalk: 5.6.2 + chmodrp: 1.0.2 + cmake-js: 8.0.0 + cross-spawn: 7.0.6 + env-var: 7.5.0 + filenamify: 6.0.0 + fs-extra: 11.4.0 + ignore: 7.0.6 + ipull: 3.9.5 + is-unicode-supported: 2.1.0 + lifecycle-utils: 3.1.1 + log-symbols: 7.0.1 + nanoid: 5.1.16 + node-addon-api: 8.9.1 + ora: 9.4.1 + pretty-ms: 9.3.0 + proper-lockfile: 4.1.2 + semver: 7.8.1 + simple-git: 3.36.0 + slice-ansi: 8.0.0 + stdout-update: 4.0.1 + strip-ansi: 7.2.0 + validate-npm-package-name: 7.0.2 + which: 6.0.1 + yargs: 17.7.3 + optionalDependencies: + '@node-llama-cpp/linux-arm64': 3.19.1 + '@node-llama-cpp/linux-armv7l': 3.19.1 + '@node-llama-cpp/linux-riscv64': 3.19.1 + '@node-llama-cpp/linux-x64': 3.19.1 + '@node-llama-cpp/linux-x64-cuda': 3.19.1 + '@node-llama-cpp/linux-x64-cuda-ext': 3.19.1 + '@node-llama-cpp/linux-x64-vulkan': 3.19.1 + '@node-llama-cpp/mac-arm64-metal': 3.19.1 + '@node-llama-cpp/mac-x64': 3.19.1 + '@node-llama-cpp/win-arm64': 3.19.1 + '@node-llama-cpp/win-x64': 3.19.1 + '@node-llama-cpp/win-x64-cuda': 3.19.1 + '@node-llama-cpp/win-x64-cuda-ext': 3.19.1 + '@node-llama-cpp/win-x64-vulkan': 3.19.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + number-allocator@1.0.14: dependencies: debug: 4.4.3 @@ -7677,6 +8570,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -7685,6 +8582,17 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + ora@9.4.1: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.2 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7695,12 +8603,18 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@3.0.0: {} + + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 parseurl@1.3.3: {} + path-key@3.1.1: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -7768,10 +8682,26 @@ snapshots: dependencies: xtend: 4.0.2 + pretty-bytes@6.1.1: {} + + pretty-ms@8.0.0: + dependencies: + parse-ms: 3.0.0 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} process@0.11.10: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} proxy-addr@2.0.7: @@ -7779,6 +8709,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pure-rand@8.4.2: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -7792,6 +8724,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -7988,10 +8927,21 @@ snapshots: remeda@2.39.0: {} + require-directory@2.1.1: {} + reselect@5.1.1: {} resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + retry@0.13.1: {} + rfdc@1.4.1: {} rollup@4.60.4: @@ -8106,6 +9056,12 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shell-quote@1.8.4: {} shiki@4.2.0: @@ -8149,6 +9105,32 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-git@3.36.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + sleep-promise@9.1.0: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} socks@2.8.9: @@ -8182,6 +9164,34 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.3.2: {} + + stdout-update@4.0.1: + dependencies: + ansi-escapes: 6.2.1 + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + steno@4.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -8191,6 +9201,16 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -8236,6 +9256,14 @@ snapshots: tapable@2.3.3: {} + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -8351,8 +9379,12 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unpipe@1.0.0: {} + url-join@4.0.1: {} + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -8379,6 +9411,8 @@ snapshots: util-deprecate@1.0.2: {} + validate-npm-package-name@7.0.2: {} + vary@1.1.2: {} vfile-location@5.0.3: @@ -8513,10 +9547,18 @@ snapshots: web-namespaces@2.0.1: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + which@4.0.0: dependencies: isexe: 3.1.5 + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -8549,15 +9591,39 @@ snapshots: worker-timers-broker: 8.0.16 worker-timers-worker: 9.0.14 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.21.0: {} xtend@4.0.2: {} + y18n@5.0.8: {} + + yallist@5.0.0: {} + yaml@2.9.0: optional: true + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yoctocolors@2.2.0: {} + zod@4.4.3: {} zwitch@2.0.4: {}