@chatwarden/core
v0.1.5
Published
Chat moderation engine for games: reports, warnings, timeouts and bans, a moderator queue and player notices. No sockets, HTTP or disk.
Maintainers
Readme
@chatwarden/core
The moderation engine of Chatwarden, the open-source chat moderation kit for games. You give it your chat lines, your reports and a small description of how to tell players apart. It decides what to deliver, what to refuse, who to time out, and what a moderator sees. It does not open sockets, serve HTTP or touch a disk: those are the other packages.
What it does:
- Chat. Checks each line against your matcher, a length and rate limit, and the sanctions in force. A line is delivered, or refused with a reason.
- Reports. Players report each other. The kit keeps the evidence, limits abuse of the report button, and builds the moderators' queue.
- Decisions and sanctions. A moderator decides a report. The kit writes the warnings, timeouts and bans, with a notice for the player and an audit row for the staff.
- A written standard. One preset (
relaxed,standardorstrict) sets the ladder, the starter Community Rules and the code of conduct players read. - Identity without identifiers. Client keys and IP addresses are hashed on the way in, and people are tracked by an opaque subject id.
It has no runtime dependencies and imports no node: module, so it also runs in a browser.
Status: pre-release, as-is, no support promise.
Install
npm install @chatwarden/coreCore stores nothing itself. To run the quick start below you also need a store and a matcher:
npm install @chatwarden/core @chatwarden/store-sqlite @chatwarden/matcher@chatwarden/store-sqlite and @chatwarden/matcher (and the other kit packages) list @chatwarden/core as a peer dependency,
so your project has exactly one copy of it. Node 22.13 or newer. The package is ESM only; a CommonJS app can require() it on
Node 22.13 or newer (see the root README).
Quick start
Copy this, then change platform.identify to read your own connections.
import { createChatwarden, type Platform } from '@chatwarden/core';
import { LiveTerms } from '@chatwarden/matcher';
import { syntheticTermPack } from '@chatwarden/matcher/fixtures';
import { openStore } from '@chatwarden/store-sqlite';
// What one of your connections looks like. Yours may be a socket, a session or a request.
interface Connection {
playerToken: string; // a stable, secret per-player value (not shown to other players)
remoteAddress: string; // the REAL client address; behind a proxy, parse X-Forwarded-For yourself
}
// How to find out who is behind a connection. Give raw values: the kit hashes them before it stores anything.
const platform: Platform = {
identify: (ctx) => {
const conn = ctx as Connection;
return { clientKey: conn.playerToken, ip: conn.remoteAddress };
},
};
const store = openStore({ path: './chatwarden.db' });
// The slur matcher. This demo uses the invented test words; use your own term pack in production.
const matcher = new LiveTerms({ store, pack: syntheticTermPack(), clock: { now: () => Date.now() } });
await matcher.load();
const cw = await createChatwarden({
store,
secret: process.env.CHATWARDEN_SECRET!, // 32+ random characters; keep it stable and private
platform,
matcher,
profile: 'standard', // 'relaxed', 'standard' or 'strict'
});
// A player connects: seat them in a room under a display name.
const connection: Connection = { playerToken: 'token-from-your-login', remoteAddress: '203.0.113.7' };
cw.rooms.seatConnection('lobby', 'seat-1', connection, 'Ada');
cw.rooms.seat('lobby', 'seat-2', { clientKey: 'another-players-token', ip: '198.51.100.4' }, 'Bo'); // or by raw identity
// A chat line arrives: ask the kit what to do with it.
const result = cw.chat.accept('lobby', 'seat-1', 'hello everyone');
if ('deliver' in result) {
console.log('send to the room:', result.deliver.text); // may be starred if you turned profanity starring on
} else {
console.log('tell this player only:', result.refuse.reason); // 'blocked', 'rate', 'restricted', ...
}
// Ada reports Bo from inside your game.
const filed = await cw.reports.submit({
roomId: 'lobby',
reporterSeat: 'seat-1',
targetSeat: 'seat-2',
reason: 'harassment', // optional: a one-tap report with no reason is fine (see "Reporting settings")
note: 'kept at it after I asked them to stop',
});
console.log(filed.ok ? `report ${filed.reportId} filed` : `not filed: ${filed.refused}`);
await cw.close(); // flushes background writes and closes the store; call it on shutdownFor the moderators' screens and the player's notices, mount the routes in
@chatwarden/http rather than calling the facade by hand.
What you get back
createChatwarden resolves to one object, cw, with these parts (the TypeScript types are the full reference):
| Part | What it is for |
| --- | --- |
| cw.rooms | seat, seatConnection, unseat, close: who is in which seat of which room. |
| cw.chat.accept(room, seat, text) | The answer for one line: { deliver } or { refuse: { reason } }. visibleTo applies a viewer's mutes. restricted(who) says whether a person is chat-restricted, for typing indicators. |
| cw.mutes | A player mutes another player for themselves only. |
| cw.reports | submit and addDetail (players; see "Reporting settings"), list, get and reporterRecord (staff). reporterRecord(person) is how the reports a person filed stand: { reports, open, actioned, dismissed, badFaith, suspendedUntil, reasonMatched, reasonDecided, accuracy }. reporterAccuracy(people) is how often each person's recent reports turned out to be unfounded (see "Reporter accuracy"). |
| cw.decide(input) | A moderator closes a report and writes the sanctions and notices in one transaction. Safe to send twice. reason is required, except for a no_action with no actions (a one-click dismissal): leave it out and the standard reason NO_ACTION_DEFAULT_REASON (No action needed) is stored. |
| cw.sanctions | apply and revoke, for a moderator who acts without a report. forPerson(subject) and list({ cursor?, limit?, source? }) (staff) read sanctions as SanctionGroupViews: one per group, built from the person's own row, so no IP, hash or stored value is in them. |
| cw.blocked | list({ days }) and countsByTerm({ days }) (staff), with days 1, 7 or 30: the lines the slur filter blocked, with the raw text, and the strikes per term. |
| cw.reports.dismissMany(input) | Staff, @experimental. "Nothing wrong here" for every open report about one person (scope: { person }, merged aliases included) or from one reporter (scope: { reporter }) in one call: at most 200 (BULK_DISMISS_MAX), more: true when there are more. Each report is closed like a single no_action decision with no actions and the standard reason (its own transaction, its own report.closed event); a bulk never marks bad faith and never counts toward a suspension. A case another moderator holds is skipped and counted unless ignoreClaims; dryRun counts and writes nothing. It writes no audit row: the staff layer does. Safe to run again. |
| cw.cases | The team workbench (staff): claim(person, actor, { takeOver? }) and release a soft claim on a person's case (releaseWhenDone is what a decision does: it ends the claim only when the person has no other open report, so one case per person is held until its last report is decided), next(actor, { category?, reason?, rankDownHabitual? }) to claim the oldest open case nobody holds (with rankDownHabitual, a case whose open reports all come from habitual false reporters and none has waited 24 hours is served after the ordinary ones, never skipped for good), holder / holders to read claims, and view / viewers for who has a case open. A claim lasts 10 minutes from the last beat on your clock, lives in the store (so several server processes on one database agree, and two moderators racing for one case never both win), and is a courtesy: the core never refuses a decision because of one (the HTTP routes do). Viewing is a 30 second hint kept in this process's memory and never stored; it tracks only people with an open report, and one actor keeps at most 20 cases open. A claimer that loses a database lock race to another process is retried (3 attempts) and gets the normal "claimed" answer. |
| cw.changes.token() | A cheap, opaque change token for the queue. It differs after any report filed or decided, sanction applied or revoked, rule gap closed, claim taken, taken over or released, or saved reason changed, and is the same otherwise (a claim heartbeat does not move it). It is kept in the store, so a restart does not rewind it (the in-memory store is the exception: it starts again at "0"). |
| cw.reasons | Saved decision reasons (staff): list, add({ label, text }), update(id, patch) and setActive(id, active). A label is up to 40 characters, a text up to 500 (counted as characters, an emoji is one), at most 200 reasons; a label holds no control character, a text none but a newline. |
| cw.notices | A player's pending notices, acknowledgements and "My record". Built so no identifier can leak. |
| cw.doors | check a person at the door (login, join) against bans. |
| cw.names.check | May a player use this as a name? |
| cw.history, cw.suggest | A person's recent record, and what the preset suggests as the next step. |
| cw.rules, cw.profile, cw.limits, cw.summary, cw.documents | The rules, standard and numbers in force, and the written code of conduct and guides. cw.profile and cw.summary are read live: the profile in force is your code's profile with the owner's stored edits on top (see "The editable profile"), so read it when you need it and never keep it. |
| cw.policy | The editable profile: get, save, preview, reset and refresh. The lengths a moderator is offered, the ladder, the first offences, the repeat rule, the area and severe rules and the guideline outcome texts can be edited at runtime and are stored in your store. |
| cw.gaps, cw.audit | Rule gaps for the periodic review, and the staff audit trail. |
| cw.on(type, listener) | Events: report.created, report.closed, sanction.applied, sanction.revoked, gap.created, auto.restricted. |
| cw.purge, cw.ready, cw.flush, cw.close | Housekeeping. |
Deciding a report
A moderator reads a report and decides it with one call. cw.decide closes the report, writes the sanction, writes the player's
notice and tells the people involved, all in one transaction, and sending the same decision twice applies nothing twice.
import type { DecisionResult } from '@chatwarden/core';
// The moderator decides the open report: a warning that shows the player their latest line.
const report = cw.reports.list({ status: 'open' }).items[0]!;
const decided: DecisionResult = await cw.decide({
reportId: report.id,
verdict: 'upheld', // 'noted', 'no_action' and 'bad_faith' are the others
actor: '[email protected]', // who decided, for the audit trail
reason: 'kept insulting a teammate', // private: a player never sees it
actions: [{ subject: report.targetPerson, kind: 'warning', durationMs: null, rule: 2, lineIds: report.ownLineIds[report.targetPerson]?.slice(-1) }],
});
console.log(decided.ok ? `applied ${decided.groups.length} action` : `not applied: ${decided.error}`);subject is the staff reference the report names (report.targetPerson); it never goes to a player. rule must be an active Community
Rule, or 'uncovered' with a gapNote. lineIds picks up to five of that player's own lines in the report to quote in their notice,
with blocked words starred out. A timeout is kind: 'chat' with durationMs. cw.suggest(subject, level, rule) says what the preset
would do next, for a moderator who wants a starting point. A failing store rejects the call, so catch it.
The editable profile
The profile you give createChatwarden (a preset and overrides) is the base. An owner can change part of it while the game
runs: the lengths a moderator is offered (durations, and whether customDuration is allowed), the escalation ladder, the
firstOffense per conduct level, the repeat rule, areaRules and severeRules, and the outcome text of each written guideline
tier, and the reporting settings (see "Reporting settings"). The edits are a validated, versioned layer kept in your store, and cw.profile is the base with that layer on top.
Profanity handling is not editable: it stays in your code. Sanctions already given are not touched.
import { createChatwarden, type Platform } from '@chatwarden/core';
import { createMemoryStore } from '@chatwarden/store-memory';
const platform: Platform = { identify: (ctx) => ctx as { clientKey: string } };
const cw = await createChatwarden({ store: createMemoryStore(), secret: 'a-long-random-secret', platform, retention: false });
const HOUR = 3_600_000;
const { version } = cw.policy.get(); // 0 until something is saved
// What would this suggest for a clean record, a warning, a 24 hour restriction and a 7 day ban? Nothing is saved.
const layer = { durations: [HOUR, 6 * HOUR, 12 * HOUR, 24 * HOUR], firstOffense: { rude: { kind: 'chat' as const, durationMs: 6 * HOUR } } };
const preview = cw.policy.preview(layer);
if (preview.ok) console.log(preview.samples.clean.rude); // { label: 'Restrict chat 6 hours', kind: 'chat', durationMs: 21600000 }
// Save it. `version` is the one you loaded: if someone saved since, the answer is { ok: false, error: 'conflict' }.
const saved = cw.policy.save(layer, { version, actor: '[email protected]', reason: 'offer shorter timeouts' });
if (saved.ok) console.log(cw.profile.durations, saved.changed); // in force at once, here
cw.policy.reset('durations'); // put one section back to the base; reset() puts everything backcw.policy.get()returns{ effective, base, layer, version, canSave }.layerError({ path, kind }) is also there when the stored layer cannot be used: it is then ignored, the base stays in force, and onechatwarden.policy_layer_invalidline is logged.kindis'unreadable'when the stored text could not be read at all (corrupted, not JSON, not an object), so the owner's edits are gone until they save again, and'invalid'when it was read but a setting is not valid (a field from a newer kit, say). A save or a fullreset()repairs either.save(layer, { version, actor, reason })stores the whole layer: a section you leave out goes back to the base. It answers{ ok: true, version, changed },{ ok: false, error: 'conflict' }for a staleversion(nothing is written),{ ok: false, error: 'invalid', path }for a layer that touches a setting that is not editable or would make the profile invalid (pathnames the field, such aslayer.profanityorlayer.ladder[2].high.durationMs; no value), and{ ok: false, error: 'unsupported' }when the store has noloadPolicyLayerandsavePolicyLayer.actorandreasonare checked here;@chatwarden/httpwrites them to the audit trail.guidelinesis positional. Entryiof the layer'sguidelineslist is the newoutcometext for tieriof the profile's ownguidelines(nullleaves a tier alone). If you reorder or add tiers in the profile in your code, stored texts keep their positions, so they land on the wrong tiers: resetguidelines(or save it again) after such a change. A stored list longer than the profile's tiers is unusable and reported aslayerError.- Whole outcomes. An outcome written as an object in a layer (
firstOffense,repeat.outcome) needs bothkindanddurationMs, so a stored layer never changes meaning when your base profile does.areaRulesandsevereRuleslist at most 100 rule numbers each. - Types.
ModerationProfileis what you write;durationsandcustomDurationare optional in it, so a profile written for 0.1.1 still compiles and works everywhere (validateProfile, the document functions,defineProfile,createChatwarden). What the kit hands back (cw.profile,cw.policy.get(),defineProfile,resolveProfile) is aResolvedProfilewith both filled in, andcw.documents.policyJson()'sprofiletherefore has the two keys even for a profile that had none. - Audit. A save and a reset are recorded by
@chatwarden/httpaspolicy.updateandpolicy.resetrows whosedetailis{ sections, reason? }. If you write your ownStore, keep and return thatdetailfrominsertAuditandlistAudit. - A ladder cell without a
labelgets one from its kind and length: "Restrict chat 6 hours", "Ban from public rooms 12 hours", "Full ban 3 days", "Full ban permanent". durationsis a sorted list of lengths, each from 1 millisecond to 365 days, at most 50. It is only the list a moderator is offered:cw.decideandcw.sanctions.applystill accept any valid length. The staff routes in@chatwarden/httprefuse a length outside the list (400duration_not_offered) whencustomDurationis false.- Other processes see a save within 10 seconds: a read of the profile looks at the stored version at most that often, on your
clock.cw.policy.refresh()looks now. - Your own store.
loadPolicyLayer()andsavePolicyLayer(layer, expectedVersion)are optional onStore. The second must be one atomic conditional write that returns the new version, ornullwhen the stored version is notexpectedVersion. A store without them still works;saveandresetanswerunsupportedand the profile stays code-only.
Reporting settings
How players report is part of the profile, so it has defaults and an owner can edit it with the rest (cw.policy, section reporting). Nothing
is required: a profile that says nothing about reporting gets the defaults.
flow:'one_tap'(the default: one step, where the player may pick a reason and add a note in the same dialog and neither is needed),'reason_first'(the screen asks for a reason first; the kit takes a report with no reason all the same, only the screen differs) or'reason_required'(a report with no reason is refusedreason_required).reasons: the ordered list a player picks from, 1 to 20 of them. Each is{ id, label, hint, group, rules, asksNote, enabled }; onlyid(a unique lowercase slug) andlabelare needed when you write one.rulesare the Community Rule numbers the reason usually means: the first citable one is the rule a moderator is suggested, and a decision under any of them counts as "matched" for the reporter's accuracy. At least one reason must be on, andothercan be relabelled and re-mapped but not removed or switched off. The defaults, in order: Chat:harassment(Insults or harassment, CR 2),hate(Hate, CR 1),sexual(Sexual content, CR 3),threat(Threats, CR 2),spam(Spam or ads, CR 4),personal_info(Sharing personal info, CR 2); Gameplay:cheating(Cheating, CR 5),griefing(Throwing or griefing, CR 6),afk(Leaving or AFK, CR 6); thenother(Something else, asks for a note).thanks: what the reporter is told afterwards. Default "Thanks for the report. We'll look into it."
// One tap, no reason: accepted. (This is the default flow.)
await cw.reports.submit({ roomId: 'lobby', reporterSeat: 'seat-1', targetSeat: 'seat-2' });
// A reason the studio did not list, or switched off, is refused in every flow.
await cw.reports.submit({ roomId: 'lobby', reporterSeat: 'seat-1', targetSeat: 'seat-3', reason: 'not_a_reason' }); // { ok: false, refused: 'reason_unknown' }
// A follow-up of your own (the kit's dialog sends the reason and note with the report): add them to an existing report.
const res = await cw.reports.addDetail(reportId, 'seat-1', { reason: 'cheating', note: 'speed hacking in the last round' });
// { ok: true }, or { ok: false, refused: 'not_found' | 'not_reporter' | 'too_late' | 'already_added' | 'reason_unknown' | 'off' }- The reason is a hint, never the truth. It is stored as
reportedReason(a reason id, or null). What settles a case is the moderator's decision, or later the AI read. The staff views show it as "Reported as ..." or "No reason given", and a report with no reason shows the lines and asks the moderator which rule applies. submitrefusals, in order:off,reason_required,reason_unknown,not_player,self,suspended,rate,duplicate. A refused reason counts against nothing. The reason is read from the profile at that moment, so an edit applies to the next report.addDetail(reportId, reporterSeat, { reason?, note? })attaches the follow-up to the report. Only the reporter can add it:reporterSeatmust be the seat the report was filed from and must still hold the reporter in that room. It works once, within ten minutes of the report (exactly ten is in time), and only while the report is open. It never changes the target or the excerpt. A reason given replaces the reported reason; a note replaces the note; one left out keeps what was there. Refusals:off(the kit is off, your store does not support follow-ups because it has noaddReportDetail, or the store failed),not_found,not_reporter,too_late,already_added,reason_unknown. A call that gives neither a reason nor a note answersok: trueand does not use up the one allowed.- The kit has no player report route. You call
cw.reports.submitfrom your own route, and you do the same foraddDetail: take the reporter's seat from your own session (never from the request body) and the report id from whatsubmitanswered, and pass the player's choice through. The player routeGET /rulesin@chatwarden/httphands the report screen its flow, enabled reasons and thank-you. - Reporter false-report rate.
cw.reports.reporterAccuracy(people)(andreporterRecord(person).accuracy) reads the reports a person filed in the last 30 days (by filing time) that are no longer open:decidedis upheld or noted (actioned) plus dismissed (noAction) plus dismissed with the bad-faith mark (badFaith),unfoundedisnoAction + badFaith, andfalseRateisunfounded / decided, or null below 5 decided reports.noticeableis a rate of 0.6 or more andhabitual0.8 or more. It is one grouped store read for up to 200 people (a store that leaves outreporterOutcomesshows none). It is a ranking hint and a line on the case, never a reason to refuse a report or to suspend anyone, and it is staff only: it never reaches a player. - Reporter accuracy.
cw.reports.reporterRecord(person)addsreasonDecided(decided reports they filed that named a reason) andreasonMatched(those where the decision cited, against the reported person, a rule in the reason'srules). A dismissal never matches. A reason that is no longer in the list, and one that maps to no rule (otherby default, so an honest "Something else" is never counted as inaccurate), cannot be judged and are left out; a switched-off one still counts. It reads the newest 200 decided reports. It is read against the reasons in force now. It is a signal for moderators and is never used to refuse a report. - From 0.1.1.
ReportInput.categoryis still accepted as an alias ofreason(the six old ids are valid reasons,disruptionreads asgriefing), andreasonwins when both are given; a blankreasonfalls back tocategory. A report's storedcategoryis still one of the six old ids, derived from its reason ('other'when none), so code that reads it keeps working;ReportCategoryis unchanged. The alias keeps its 0.1.1 leniency: acategorythat is not an enabled reason (unknown, not text, or switched off) is taken and stored as'other', as before. Only the newreasonfield is refused (reason_unknown). Reports stored before 0.1.2 read their old category as theirreportedReason. Upgrading:ReportRefusalhas two new members (reason_required,reason_unknown),addDetailhas its own result type, andlayerErrorgains a requiredkind; an exhaustiveRecord<ReportRefusal, ...>or aswitchwith anevercheck over them needs the new cases to compile. - Your own store.
addReportDetailandloadReporterDecisionsare optional onStore. Without the first,addDetailanswersoff; without the second, the accuracy counts are 0.insertReportmust keepreportedReason, andlistReportsmust apply thereasonfilter (nullmeans none).
Typing indicators
Do not show a "typing..." indicator for a player who cannot chat. cw.chat.restricted(who, { roomId, seatId }) runs the
same decision as accept (a chat restriction, a ban, or the automatic timeout), without sending a line. It is
synchronous and has no side effects: it spends no rate budget and writes no evidence, strike, event or notice.
import type { Chatwarden, RawWho } from '@chatwarden/core';
// Call it when a client says "I am typing", after `cw.rooms.seat` for that seat. `who` is what your platform's
// `identify` returned for this connection on the server, never a key or account the client sent.
function relayTyping(cw: Chatwarden, who: RawWho, roomId: string, seatId: string, tell: () => void): void {
const { restricted, until, kind } = cw.chat.restricted(who, { roomId, seatId });
if (restricted) return; // drop it quietly: do not broadcast their typing
tell();
void until; // epoch ms when it ends (null = permanent), only for the person's own sanction
void kind; // 'chat', 'ban' or 'auto' (the automatic timeout), only for their own sanction
}With both roomId and seatId the answer equals what accept would say for that seat. Leave them out and an
automatic timeout that was kept only in memory (the store could not take it, or a guest with no key and no account earned
it) is looked up by person instead; a guest cannot be found that way, because one guest cannot be told from another.
The gate option is not consulted: a gate refusal is not a restriction.
It is safe to answer a player about themselves from their own client, because it reveals nothing about anyone else. A
player who is restricted only because they share an address with someone else's sanction gets
{ restricted: true, until: null, kind: null }: they cannot chat, and they are told nothing about the other person. The
address checked is the one seat last recorded for the person (as in accept), not who.ip, and an address alone finds
nobody. A lookup that fails reads as restricted: false and is logged at most once a minute (error class only, with a count of
the failures held back).
Browser support
The package imports no node: module, so a bundler (esbuild, Vite, webpack) builds it for a browser with nothing external and no
polyfill. Pair it with @chatwarden/store-memory, which
is also browser-safe, and with @chatwarden/matcher for the live matcher. That is what the repository's CI bundles and runs,
in a context with no process, Buffer or require, through a full report, decision and notice flow.
The hashing of client keys and IP addresses uses node:crypto when the runtime offers it (looked up at run time through
process.getBuiltinModule, never imported) and a small pure-JS SHA-256 and HMAC-SHA256 where it does not. Both give the same
bytes, and they match the hashes of 0.1.0, so moving a store between a server and a browser build never unlinks a stored hash.
The pure-JS path is slower, which only matters for a very large number of lookups. A browser build is meant for tests, demos and
tools; the kit still decides on a server you control, because anything running on a player's machine can be changed by that
player. cw.chat.restricted(who, { roomId, seatId }) (see "Typing indicators") is a plain synchronous call and works the same
in a browser.
Options
createChatwarden(options). Only store, secret and platform are required.
| Option | What it does |
| --- | --- |
| store | Where moderation state lives, for example openStore(...) from @chatwarden/store-sqlite. |
| secret | Keys the client-key and IP hashes. Keep it stable: changing it unlinks every stored hash. |
| platform | identify(ctx) returns { clientKey?, accountId?, ip? } for a connection; optional notify(subject, event) tells you a player has notices waiting. |
| matcher | Your slur matcher (LiveTerms from @chatwarden/matcher). Without one, nothing is ever blocked. |
| profanity | A ready profanity filter. What happens to a profane line is the profile's call (allow, star or refuse). |
| profanityPackage | { createProfanityFilter } from @chatwarden/profanity; the kit builds the filter from the profile. Not used when profanity is given. |
| clock | { now() }. Defaults to the system clock; tests pass their own. |
| enabled | A kill switch, read on every call. Return false to stop enforcement and report intake. |
| gate | (roomId, subject) => boolean: who may chat. False refuses the line with 'gate'. |
| onReport | Told once for each stored report. It carries subject ids and the evidence: staff channels only. |
| onAutoRestrict | Told when a blocked line earned an automatic timeout. The same moment is also the auto.restricted event. |
| retention | How long rows are kept and how often the purge runs (every 6 hours by default), or false to run purge() yourself. Defaults: reports 90 days, strikes 30, notices 90, audit 365. Floors apply. |
| profile | 'relaxed', 'standard' (default), 'strict', or your own profile from defineProfile. |
| overrides | Changes to the profile, merged onto it ({ profanity: { mode: 'allow' } }). A list you give replaces the preset's list. |
| limits | The numbers the kit enforces: line length and rate, strikes and timeouts, report limits. Each must be a whole number above 0. |
| logger | { warn(event, detail?) }. Defaults to console.warn. It only ever receives an event name and an error class. |
| rules | The numbered Community Rules, from defineRules. Defaults to the preset's starter set. |
| autoRestrictRule | The rule number the automatic slur filter cites (default 1). Must be an active rule. |
Bad options throw at creation (ProfileError, RangeError), before the store is touched.
Security notes
- What is hashed. A client key and an IP address are hashed with HMAC-SHA256 under your
secret(the first 128 bits, with a separate key space for each kind) before they are stored or compared. The raw values are never written. An account id is not hashed: it is stored exactly as you give it, so pass an opaque id, never an email address or a name. - The secret. Keep
secretout of your repository and your logs. Anyone who holds both the secret and the database can test a guessed IP address against the stored hashes. Changing it unlinks everyone's history, so rotate it on purpose, not by accident. - What is never logged. The kit never logs chat text, a client key, an IP address, a hash, a subject id, a term or a reason.
A log line carries an event name and, at most, the error class (
chatwarden.purge_failed,error: 'Error'). A logger that throws is swallowed and never breaks chat. - Subject ids are staff-only. A subject id names a person. It appears in staff reads (
cw.reports,cw.decide,cw.history,cw.onevents,onReport) and inplatform.notify, where you use it only on the server to find that player's connection. Never send a subject id to a player, a public channel or a browser. Everything meant for a player (cw.notices, the code of conduct, a refusal) is built without one. - Chat text is kept in two places only. An evidence buffer in memory (the last 100 lines or 15 minutes of each room), and the excerpt copied into a report or a notice when one is made. Reports and notices are deleted by retention. A player's notice quotes only their own lines.
- Moderators' words stay with moderators. A decision's reason, a gap note, a reporter's note and a moderator's identity are never shown to a player.
- The kit enforces nothing about who may call it. It has no login. Put your own authentication in front of every staff read,
which is what the
authorizehook of@chatwarden/httpis for.
Links
- Chatwarden on GitHub and the root README
@chatwarden/http: routes for moderators and players, health probes, webhooks@chatwarden/store-sqlite,@chatwarden/matcher,@chatwarden/profanity,@chatwarden/discord- Report a problem
- Licence: Apache-2.0
