From e25082a92dbe89e6e7c007b6e0ab66cf58a77e95 Mon Sep 17 00:00:00 2001 From: ChronoFinale Date: Thu, 6 Aug 2026 18:43:56 -0500 Subject: [PATCH] feat(chat): moderated free-form chat Adds apps/moderation - a verdict service - and routes chat through it. **The relay is unchanged until MODERATION_SERVICE_URL is set.** Unset, the local obscenity filter runs exactly as before and the new response branches are unreachable. The dev compose does set it, so `docker compose up` gives you a moderated stack out of the box - that needs a model in the moderation volume, and without one the service reports not-ready and chat fails closed rather than going through unmoderated. Run with `MODERATION_SERVICE_URL=` to get the old behaviour back. Message in, verdict out, nothing persisted: no database, no per-player state beyond an in-memory rate-limit bucket. An opaque playerId is the only identity that crosses the boundary, so this box cannot answer "what did this Steam account say". A funnel sorted by certainty rather than severity. A transform tier strips links whose domain is not approved and applies vocabulary rewrites; the transformed text is what every later tier judges AND publishes. Then rate-limit, threats, allowlist fast-pass, blocklist, PII/contact. Only the ambiguous middle reaches the model. The allowlist alone fast-passes about 60% of real chat - "good luck have fun" goes from 1301ms to 1ms - so the word lists ship with the service rather than being copied onto a box by hand. It judges one message and never punishes anyone: bans, mutes and strikes belong to whatever calls it. A message that is not already an allowlisted preset is sent for a verdict and published only on an explicit allow. Any timeout, non-200, redirect, unparseable body or unrecognised verdict fails closed. The allowlist short-circuit runs before the call, so an outage degrades to preset-only chat rather than no chat. A rewritten message is published and returned to the sender as publishText so a client can show what others received, while the evidence buffer and reported-message record keep what the player typed. A rewrite must never launder the record. Details that are easy to get wrong, and why they are the way they are: - redirect: 'error' - a verdict must come from the configured origin, not wherever a redirect leads. - The client deadline exceeds the service's judgement deadline, or a slow but successful verdict is abandoned here while still occupying the service's single model lane, and the retry deepens the backlog that caused it. - HTTP 429 is the service shedding load, not this player being too fast, so it reports an outage. Per-player limiting arrives as a 200 with band rate_limited and is the only thing told to slow down. - guard_unavailable means the model was down; the player is not told they broke a rule. Two variables for the service (GUARD_MODEL, SHADOW_MODE) and one for the relay (MODERATION_SERVICE_URL). No bearer token: the service publishes no host port, so it is reached only by the relay over an internal network. No resource limits either - uncapped, the container sees every host core and the guard matches its thread count to them, so the two cannot disagree. Capping CPUs WITHOUT also setting GUARD_THREADS is the one configuration that fails badly, because llama.cpp threads spin-wait and oversubscription collapses throughput instead of degrading it. The model is not in the image or the repo - it is far past GitHub's file limit, and baking it in would publish a fine-tune with every pull. Put the .gguf in the model volume once; it survives restarts and rebuilds. Without one the service still starts, reports not-ready, and chat fails closed. Ships in shadow mode: the guard logs what it would block without blocking, and the deterministic tiers still enforce. Turning the bridge on with stock defaults is therefore MORE permissive than the local filter it replaces, not less - that is the intended first step, but it should be a deliberate one. 443 relay + 228 service unit tests, no infra required. The e2e suite drives the whole stack for real: a clean message reaches the other player over MQTT, a violent threat is refused and published to nobody, the refusal reads as something a player can understand, and a burst is rate-limited without being reported as an outage. Getting that suite honest meant fixing three things in the harness that made it report success it had not earned: the api build context could never rebuild the image, so a stale binary was under test; chat is disabled by default at two gates, so every message was refused before moderation ran and the reject tests passed on that; and database seeding trusted whatever answered on a port, which a stray tunnel to another host had been shadowing. --- apps/moderation/Dockerfile | 46 + apps/moderation/README.md | 127 + apps/moderation/config/allowlist.txt | 5105 +++++++++++++++++ apps/moderation/config/approved-domains.txt | 12 + apps/moderation/config/rewrites.txt | 21 + apps/moderation/package.json | 29 + apps/moderation/src/guard/engine.test.ts | 54 + apps/moderation/src/guard/engine.ts | 159 + apps/moderation/src/guard/prompt.test.ts | 69 + apps/moderation/src/guard/prompt.ts | 87 + apps/moderation/src/main.ts | 304 + apps/moderation/src/pipeline/analyze.test.ts | 90 + apps/moderation/src/pipeline/analyze.ts | 169 + apps/moderation/src/pipeline/decide.test.ts | 495 ++ apps/moderation/src/pipeline/decide.ts | 207 + apps/moderation/src/pipeline/index.ts | 30 + apps/moderation/src/pipeline/links.test.ts | 56 + apps/moderation/src/pipeline/links.ts | 64 + .../moderation/src/pipeline/normalize.test.ts | 105 + apps/moderation/src/pipeline/normalize.ts | 97 + apps/moderation/src/pipeline/policy.ts | 25 + .../src/pipeline/rate-limit.test.ts | 201 + apps/moderation/src/pipeline/rate-limit.ts | 71 + apps/moderation/src/pipeline/rewrite.test.ts | 107 + apps/moderation/src/pipeline/rewrite.ts | 83 + apps/moderation/src/pipeline/threat.test.ts | 90 + apps/moderation/src/pipeline/threat.ts | 212 + apps/moderation/src/pipeline/types.ts | 134 + .../src/safety/contact-exchange.test.ts | 330 ++ .../moderation/src/safety/contact-exchange.ts | 336 ++ apps/moderation/src/service/admission.ts | 32 + apps/moderation/src/service/allowlist.test.ts | 35 + apps/moderation/src/service/allowlist.ts | 19 + .../moderation/src/service/model-path.test.ts | 85 + apps/moderation/src/service/model-path.ts | 70 + apps/moderation/src/service/posture.test.ts | 48 + apps/moderation/src/service/posture.ts | 35 + apps/moderation/src/service/server.test.ts | 287 + apps/moderation/src/service/server.ts | 158 + apps/moderation/src/service/service.test.ts | 480 ++ apps/moderation/src/service/service.ts | 310 + apps/moderation/tsconfig.build.json | 22 + apps/moderation/tsconfig.json | 10 + apps/moderation/vitest.config.ts | 8 + apps/server/.env.example | 10 +- apps/server/src/env.ts | 22 + apps/server/src/features/chat/chat.service.ts | 76 +- apps/server/src/features/chat/moderation.ts | 80 + apps/server/src/features/lobby/lobby.route.ts | 7 +- .../infrastructure/gateways/chat.gateway.ts | 7 +- .../gateways/moderation.gateway.ts | 72 + apps/server/src/main.ts | 8 + .../src/tests/routes/chat.moderation.test.ts | 126 + .../src/tests/services/chat.service.test.ts | 289 + .../tests/services/moderation.gateway.test.ts | 214 + .../src/tests/services/moderation.test.ts | 214 + docker-compose.yml | 27 + package.json | 2 +- pnpm-lock.yaml | 1070 +++- 59 files changed, 12726 insertions(+), 12 deletions(-) create mode 100644 apps/moderation/Dockerfile create mode 100644 apps/moderation/README.md create mode 100644 apps/moderation/config/allowlist.txt create mode 100644 apps/moderation/config/approved-domains.txt create mode 100644 apps/moderation/config/rewrites.txt create mode 100644 apps/moderation/package.json create mode 100644 apps/moderation/src/guard/engine.test.ts create mode 100644 apps/moderation/src/guard/engine.ts create mode 100644 apps/moderation/src/guard/prompt.test.ts create mode 100644 apps/moderation/src/guard/prompt.ts create mode 100644 apps/moderation/src/main.ts create mode 100644 apps/moderation/src/pipeline/analyze.test.ts create mode 100644 apps/moderation/src/pipeline/analyze.ts create mode 100644 apps/moderation/src/pipeline/decide.test.ts create mode 100644 apps/moderation/src/pipeline/decide.ts create mode 100644 apps/moderation/src/pipeline/index.ts create mode 100644 apps/moderation/src/pipeline/links.test.ts create mode 100644 apps/moderation/src/pipeline/links.ts create mode 100644 apps/moderation/src/pipeline/normalize.test.ts create mode 100644 apps/moderation/src/pipeline/normalize.ts create mode 100644 apps/moderation/src/pipeline/policy.ts create mode 100644 apps/moderation/src/pipeline/rate-limit.test.ts create mode 100644 apps/moderation/src/pipeline/rate-limit.ts create mode 100644 apps/moderation/src/pipeline/rewrite.test.ts create mode 100644 apps/moderation/src/pipeline/rewrite.ts create mode 100644 apps/moderation/src/pipeline/threat.test.ts create mode 100644 apps/moderation/src/pipeline/threat.ts create mode 100644 apps/moderation/src/pipeline/types.ts create mode 100644 apps/moderation/src/safety/contact-exchange.test.ts create mode 100644 apps/moderation/src/safety/contact-exchange.ts create mode 100644 apps/moderation/src/service/admission.ts create mode 100644 apps/moderation/src/service/allowlist.test.ts create mode 100644 apps/moderation/src/service/allowlist.ts create mode 100644 apps/moderation/src/service/model-path.test.ts create mode 100644 apps/moderation/src/service/model-path.ts create mode 100644 apps/moderation/src/service/posture.test.ts create mode 100644 apps/moderation/src/service/posture.ts create mode 100644 apps/moderation/src/service/server.test.ts create mode 100644 apps/moderation/src/service/server.ts create mode 100644 apps/moderation/src/service/service.test.ts create mode 100644 apps/moderation/src/service/service.ts create mode 100644 apps/moderation/tsconfig.build.json create mode 100644 apps/moderation/tsconfig.json create mode 100644 apps/moderation/vitest.config.ts create mode 100644 apps/server/src/features/chat/moderation.ts create mode 100644 apps/server/src/infrastructure/gateways/moderation.gateway.ts create mode 100644 apps/server/src/tests/routes/chat.moderation.test.ts create mode 100644 apps/server/src/tests/services/chat.service.test.ts create mode 100644 apps/server/src/tests/services/moderation.gateway.test.ts create mode 100644 apps/server/src/tests/services/moderation.test.ts 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: {}