serverless-ircd 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed base64 helpers backed by the global `atob` / `btoa` available on
|
|
3
|
+
* every supported runtime (Node 16+, Cloudflare Workers, browsers). The
|
|
4
|
+
* project pins `lib: ES2022` which omits these names from the type table,
|
|
5
|
+
* so this module re-exports them with proper signatures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Decodes a base64 string to a Latin1/binary string (char per byte). */
|
|
9
|
+
export function decodeBase64(input: string): string {
|
|
10
|
+
return (globalThis as unknown as { atob: (s: string) => string }).atob(input);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Encodes a Latin1/binary string (char per byte, 0–255) to base64. */
|
|
14
|
+
export function encodeBase64(input: string): string {
|
|
15
|
+
return (globalThis as unknown as { btoa: (s: string) => string }).btoa(input);
|
|
16
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { IrcMessage, IrcSource } from './messages.js';
|
|
2
|
-
export { IrcParseError, parse, unescapeTagValue } from './parser.js';
|
|
2
|
+
export { MAX_INPUT_BYTES, IrcParseError, parse, unescapeTagValue } from './parser.js';
|
|
3
3
|
export { escapeTagValue, serialize, serializeFrame } from './serializer.js';
|
|
4
4
|
export {
|
|
5
5
|
MAX_LINE_BYTES,
|
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
formatServerTime,
|
|
10
10
|
} from './outbound.js';
|
|
11
11
|
export { wrapBatch, type BatchFrame } from './batch.js';
|
|
12
|
+
export { decodeBase64, encodeBase64 } from './base64.js';
|
|
12
13
|
export {
|
|
13
14
|
formatNumeric,
|
|
14
15
|
isNumericCommand,
|
|
@@ -77,6 +77,17 @@ export const Numerics = {
|
|
|
77
77
|
RPL_YOURESERVICE: 383,
|
|
78
78
|
RPL_TIME: 391,
|
|
79
79
|
|
|
80
|
+
// SASL (IRCv3 sasl-3.2)
|
|
81
|
+
RPL_LOGGEDIN: 900,
|
|
82
|
+
RPL_LOGGEDOUT: 901,
|
|
83
|
+
ERR_NICKLOCKED: 902,
|
|
84
|
+
RPL_SASLSUCCESS: 903,
|
|
85
|
+
ERR_SASLFAIL: 904,
|
|
86
|
+
ERR_SASLTOOLONG: 905,
|
|
87
|
+
ERR_SASLABORT: 906,
|
|
88
|
+
ERR_SASLALREADY: 907,
|
|
89
|
+
ERR_SASLMECHS: 908,
|
|
90
|
+
|
|
80
91
|
// Errors
|
|
81
92
|
ERR_NOSUCHNICK: 401,
|
|
82
93
|
ERR_NOSUCHSERVER: 402,
|
|
@@ -16,6 +16,19 @@ export class IrcParseError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* RFC 1459 §2.3: maximum IRC message length in bytes. The spec counts the
|
|
21
|
+
* trailing CR-LF as part of the 512 budget; the parser strips CR-LF first,
|
|
22
|
+
* so this constant caps the post-strip body. Adopted verbatim by the
|
|
23
|
+
* output side (`outbound.ts` `MAX_LINE_BYTES`) — the same number everywhere
|
|
24
|
+
* so the wire contract reads "no IRC message exceeds 512 bytes".
|
|
25
|
+
*
|
|
26
|
+
* Applied to inputs as a hard cap: a single line longer than this is a
|
|
27
|
+
* protocol violation (the client is misbehaving or a downstream framer is
|
|
28
|
+
* broken) and is rejected with `IrcParseError` rather than parsed.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_INPUT_BYTES = 512;
|
|
31
|
+
|
|
19
32
|
const TAG_ESCAPES: Readonly<Record<string, string>> = {
|
|
20
33
|
':': ';',
|
|
21
34
|
s: ' ',
|
|
@@ -97,11 +110,23 @@ const COMMAND_RE = /^(?:[A-Za-z]+|[0-9]{3})$/;
|
|
|
97
110
|
export function parse(line: string): IrcMessage {
|
|
98
111
|
// Strip any trailing CR/LF (frame tolerance for ws text frames or TCP lines).
|
|
99
112
|
const stripped = line.replace(/[\r\n]+$/u, '');
|
|
100
|
-
|
|
101
|
-
if (rest.length === 0) {
|
|
113
|
+
if (stripped.length === 0) {
|
|
102
114
|
throw new IrcParseError('empty message', line);
|
|
103
115
|
}
|
|
104
116
|
|
|
117
|
+
// RFC 1459 §2.3 hard cap: reject any single message whose post-CRLF body
|
|
118
|
+
// exceeds {@link MAX_INPUT_BYTES} bytes. Prevents memory and processing
|
|
119
|
+
// abuse from over-long inputs and matches the symmetric cap enforced on
|
|
120
|
+
// outbound lines by `outbound.ts`.
|
|
121
|
+
if (stripped.length > MAX_INPUT_BYTES) {
|
|
122
|
+
throw new IrcParseError(
|
|
123
|
+
`message exceeds ${MAX_INPUT_BYTES}-byte input cap (${stripped.length} bytes)`,
|
|
124
|
+
line,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let rest = stripped;
|
|
129
|
+
|
|
105
130
|
let tags: Record<string, string> = {};
|
|
106
131
|
if (rest.startsWith('@')) {
|
|
107
132
|
const space = rest.indexOf(' ');
|
|
@@ -29,6 +29,20 @@ export interface UserModes {
|
|
|
29
29
|
serverNotices: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Per-connection token bucket used by the flood-control wrapper.
|
|
34
|
+
*
|
|
35
|
+
* Lazily initialized on the first observed message. `tokens` may go negative
|
|
36
|
+
* down to the configured `-disconnectThreshold`; once it crosses that line
|
|
37
|
+
* the wrapper emits `Disconnect("Excess Flood")`.
|
|
38
|
+
*/
|
|
39
|
+
export interface FloodBucketState {
|
|
40
|
+
/** Current token balance; may be negative up to `-threshold`. */
|
|
41
|
+
tokens: number;
|
|
42
|
+
/** Epoch ms of the last refill (source: injected `Clock`). */
|
|
43
|
+
lastRefill: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* Mutable, authoritative per-connection state.
|
|
34
48
|
*
|
|
@@ -50,6 +64,19 @@ export interface ConnectionState {
|
|
|
50
64
|
passAttempt?: string;
|
|
51
65
|
/** SASL-authenticated account name, if any. */
|
|
52
66
|
account?: string;
|
|
67
|
+
/**
|
|
68
|
+
* SASL mechanism currently being negotiated. Set on `AUTHENTICATE <MECH>`
|
|
69
|
+
* and cleared on success, failure, or abort. While set, the connection
|
|
70
|
+
* expects the next `AUTHENTICATE` frame to carry (or continue) the
|
|
71
|
+
* mechanism's payload.
|
|
72
|
+
*/
|
|
73
|
+
saslMech?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Buffered SASL payload chunks for multi-line `AUTHENTICATE` frames.
|
|
76
|
+
* Populated when the client splits a payload across `>400`-byte chunks
|
|
77
|
+
* and consumed once the final short chunk (or `+`) arrives.
|
|
78
|
+
*/
|
|
79
|
+
saslBuffer?: string;
|
|
53
80
|
registration: RegistrationState;
|
|
54
81
|
/**
|
|
55
82
|
* True once the client has sent `CAP LS` or `CAP REQ` and not yet sent
|
|
@@ -68,6 +95,20 @@ export interface ConnectionState {
|
|
|
68
95
|
lastSeen: number;
|
|
69
96
|
/** Connect-time epoch ms (drives WHOIS signon time). */
|
|
70
97
|
connectedSince: number;
|
|
98
|
+
/**
|
|
99
|
+
* Per-connection token bucket consulted by the flood-control wrapper
|
|
100
|
+
* (`wrapWithFloodControl`). Absent until the wrapper sees its first
|
|
101
|
+
* message, so existing persisted state migrates without a schema bump.
|
|
102
|
+
*/
|
|
103
|
+
floodBucket?: FloodBucketState;
|
|
104
|
+
/**
|
|
105
|
+
* Per-channel "last read" msgid marker for IRCv3 `draft/chathistory`
|
|
106
|
+
* playback. Keyed by lowercased channel name; the value is the msgid of
|
|
107
|
+
* the most recent message replayed (on JOIN) or queried (on CHATHISTORY).
|
|
108
|
+
* Subsequent JOINs only replay messages newer than this marker, so a
|
|
109
|
+
* PART/re-JOIN cycle with no new activity replays nothing.
|
|
110
|
+
*/
|
|
111
|
+
lastReadMarkers?: Map<string, string>;
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
/**
|
|
@@ -7,10 +7,35 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Effect } from './effects.js';
|
|
10
|
-
import type {
|
|
10
|
+
import type { FloodControlConfig } from './flood-control.js';
|
|
11
|
+
import type { AccountStore, Clock, IdFactory, MessageStore, MotdProvider } from './ports.js';
|
|
11
12
|
import type { IrcMessage } from './protocol/messages.js';
|
|
12
13
|
import type { ConnectionState } from './state/connection.js';
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Hostmask cloaking configuration as seen by reducers. Mirrors the
|
|
17
|
+
* {@link ServerConfigSchema} shape; reducers only read these fields.
|
|
18
|
+
*
|
|
19
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
20
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
21
|
+
*/
|
|
22
|
+
export interface CloakingConfig {
|
|
23
|
+
readonly enabled: boolean;
|
|
24
|
+
readonly secret: string;
|
|
25
|
+
readonly cloakedSuffix?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A single IRC operator credential. The {@link operReducer} consults the
|
|
30
|
+
* configured list to authenticate `OPER <name> <password>`. Both fields are
|
|
31
|
+
* compared verbatim; adapters are responsible for any hashing at the auth
|
|
32
|
+
* boundary if they layer one on top.
|
|
33
|
+
*/
|
|
34
|
+
export interface OperCred {
|
|
35
|
+
readonly user: string;
|
|
36
|
+
readonly password: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
/**
|
|
15
40
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
16
41
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
@@ -21,6 +46,35 @@ import type { ConnectionState } from './state/connection.js';
|
|
|
21
46
|
export interface ServerConfig {
|
|
22
47
|
serverName: string;
|
|
23
48
|
networkName: string;
|
|
49
|
+
/**
|
|
50
|
+
* Server password gate. Empty string or undefined disables enforcement.
|
|
51
|
+
* When set, registration refuses to complete until the connection
|
|
52
|
+
* supplies the matching value via `PASS`.
|
|
53
|
+
*
|
|
54
|
+
* Typed `string | undefined` (rather than just `string`) so the field is
|
|
55
|
+
* structurally compatible with Zod's `ServerConfigSchema` output under
|
|
56
|
+
* `exactOptionalPropertyTypes: true` — `.optional()` yields `T | undefined`.
|
|
57
|
+
*/
|
|
58
|
+
readonly serverPassword?: string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Cloaking config. Undefined when cloaking is disabled (the default);
|
|
61
|
+
* reducers consult `enabled` + `secret` to compute the visible host.
|
|
62
|
+
*
|
|
63
|
+
* Typed `CloakingConfig | undefined` to mirror Zod's `.optional()` output
|
|
64
|
+
* (see `serverPassword` note).
|
|
65
|
+
*/
|
|
66
|
+
readonly cloaking?: CloakingConfig | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* IRC operator credentials consulted by the `OPER` reducer. `undefined`
|
|
69
|
+
* (or an empty array) disables oper authentication entirely — an `OPER`
|
|
70
|
+
* attempt then receives `491 ERR_NOOPERHOST`.
|
|
71
|
+
*
|
|
72
|
+
* Typed `ReadonlyArray<OperCred> | undefined` to mirror Zod's `.optional()`
|
|
73
|
+
* output under `exactOptionalPropertyTypes: true`; the parsed config always
|
|
74
|
+
* supplies an array (defaulting to `[]`), so a parsed config is assignable
|
|
75
|
+
* here.
|
|
76
|
+
*/
|
|
77
|
+
readonly operCreds?: ReadonlyArray<OperCred> | undefined;
|
|
24
78
|
maxChannelsPerUser: number;
|
|
25
79
|
maxTargetsPerCommand: number;
|
|
26
80
|
maxListEntries: number;
|
|
@@ -28,6 +82,24 @@ export interface ServerConfig {
|
|
|
28
82
|
channelLen: number;
|
|
29
83
|
topicLen: number;
|
|
30
84
|
quitMessage: string;
|
|
85
|
+
/**
|
|
86
|
+
* Per-connection message-rate flood control. `undefined` → the actor
|
|
87
|
+
* applies the documented default bucket; `null` → flood control disabled;
|
|
88
|
+
* an object → use those thresholds. The optional `commandCost` is wired
|
|
89
|
+
* in code when absent (control-plane commands are exempt by default) — it
|
|
90
|
+
* cannot be serialized from a config store.
|
|
91
|
+
*/
|
|
92
|
+
readonly floodControl?: FloodControlConfig | null | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Maximum number of back-messages replayed on JOIN when the joiner has
|
|
95
|
+
* negotiated `draft/chathistory`. Defaults to 50 per the spec's
|
|
96
|
+
* recommendation when omitted. Only consulted when a {@link MessageStore}
|
|
97
|
+
* is bound.
|
|
98
|
+
*
|
|
99
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
100
|
+
* `exactOptionalPropertyTypes: true`.
|
|
101
|
+
*/
|
|
102
|
+
chatHistoryPlaybackLimit?: number | undefined;
|
|
31
103
|
}
|
|
32
104
|
|
|
33
105
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -41,6 +113,15 @@ export interface Ctx {
|
|
|
41
113
|
readonly ids: IdFactory;
|
|
42
114
|
/** Pre-loaded MOTD source for the `MOTD` reducer. */
|
|
43
115
|
readonly motd: MotdProvider;
|
|
116
|
+
/** SASL account verification source (absent when no accounts are configured). */
|
|
117
|
+
readonly accounts?: AccountStore;
|
|
118
|
+
/**
|
|
119
|
+
* Chat-history persistence source (absent when chathistory playback is
|
|
120
|
+
* disabled for this deployment). When present, the PRIVMSG/NOTICE channel
|
|
121
|
+
* reducers record every accepted message and JOIN replays the backlog for
|
|
122
|
+
* cap-enabled joiners.
|
|
123
|
+
*/
|
|
124
|
+
readonly messages?: MessageStore;
|
|
44
125
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
45
126
|
readonly connection: ConnectionState;
|
|
46
127
|
/** Convenience: `connection.id`. */
|
|
@@ -65,12 +146,14 @@ export interface BuildCtxOptions {
|
|
|
65
146
|
clock: Clock;
|
|
66
147
|
ids: IdFactory;
|
|
67
148
|
motd: MotdProvider;
|
|
149
|
+
accounts?: AccountStore;
|
|
150
|
+
messages?: MessageStore;
|
|
68
151
|
connection: ConnectionState;
|
|
69
152
|
}
|
|
70
153
|
|
|
71
154
|
/** Constructs a {@link Ctx} from the required parts. */
|
|
72
155
|
export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
73
|
-
|
|
156
|
+
const ctx: Ctx = {
|
|
74
157
|
serverConfig: opts.serverConfig,
|
|
75
158
|
serverName: opts.serverConfig.serverName,
|
|
76
159
|
networkName: opts.serverConfig.networkName,
|
|
@@ -79,5 +162,8 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
79
162
|
motd: opts.motd,
|
|
80
163
|
connection: opts.connection,
|
|
81
164
|
connId: opts.connection.id,
|
|
165
|
+
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
166
|
+
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
82
167
|
};
|
|
168
|
+
return ctx;
|
|
83
169
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
|
3
|
+
"_comment": "Mutation-testing spot-check for irc-core/commands (coverage gate hardening). Targets every IRC command reducer shipped in Phase 1 (PING/USER/JOIN/PRIVMSG/MODE/KICK/INVITE/WHO/WHOIS/LIST/CAP/SASL/ISUPPORT/MOTD/CHATHISTORY/...). Reuses vitest as the test runner and asserts a mutation score >=80% (see tools/ci-hardening/src/validate.ts for the canonical threshold).",
|
|
4
|
+
"packageManager": "npm",
|
|
5
|
+
"testRunner": "vitest",
|
|
6
|
+
"plugins": ["@stryker-mutator/vitest-runner"],
|
|
7
|
+
"coverageAnalysis": "off",
|
|
8
|
+
"mutate": [
|
|
9
|
+
"src/commands/ping.ts",
|
|
10
|
+
"src/commands/quit.ts",
|
|
11
|
+
"src/commands/registration.ts",
|
|
12
|
+
"src/commands/join.ts",
|
|
13
|
+
"src/commands/part.ts",
|
|
14
|
+
"src/commands/privmsg.ts",
|
|
15
|
+
"src/commands/topic.ts",
|
|
16
|
+
"src/commands/names.ts",
|
|
17
|
+
"src/commands/motd.ts",
|
|
18
|
+
"src/commands/mode.ts",
|
|
19
|
+
"src/commands/kick.ts",
|
|
20
|
+
"src/commands/invite.ts",
|
|
21
|
+
"src/commands/who.ts",
|
|
22
|
+
"src/commands/whois.ts",
|
|
23
|
+
"src/commands/list.ts",
|
|
24
|
+
"src/commands/cap.ts",
|
|
25
|
+
"src/commands/sasl.ts",
|
|
26
|
+
"src/commands/isupport.ts",
|
|
27
|
+
"src/commands/chathistory.ts",
|
|
28
|
+
"src/commands/away.ts",
|
|
29
|
+
"src/commands/chghost.ts"
|
|
30
|
+
],
|
|
31
|
+
"disableTypeChecks": "{src,tests}/**/*.ts",
|
|
32
|
+
"thresholds": {
|
|
33
|
+
"high": 80,
|
|
34
|
+
"low": 60,
|
|
35
|
+
"break": 80
|
|
36
|
+
},
|
|
37
|
+
"reporters": ["clear-text", "html"],
|
|
38
|
+
"timeoutMS": 5000,
|
|
39
|
+
"concurrency": 2,
|
|
40
|
+
"ignoreStatic": true
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
|
3
|
+
"_comment": "Mutation-testing spot-check for irc-core/protocol (coverage gate hardening). Targets the parser, serializer, numerics, message framing, batch framing, base64 and outbound helpers. Runs the existing vitest suite against generated mutants and fails the build below MUTATION_SCORE_THRESHOLD=80 (see tools/ci-hardening/src/validate.ts).",
|
|
4
|
+
"packageManager": "npm",
|
|
5
|
+
"testRunner": "vitest",
|
|
6
|
+
"plugins": ["@stryker-mutator/vitest-runner"],
|
|
7
|
+
"coverageAnalysis": "off",
|
|
8
|
+
"mutate": [
|
|
9
|
+
"src/protocol/parser.ts",
|
|
10
|
+
"src/protocol/serializer.ts",
|
|
11
|
+
"src/protocol/numerics.ts",
|
|
12
|
+
"src/protocol/messages.ts",
|
|
13
|
+
"src/protocol/outbound.ts",
|
|
14
|
+
"src/protocol/batch.ts",
|
|
15
|
+
"src/protocol/base64.ts"
|
|
16
|
+
],
|
|
17
|
+
"disableTypeChecks": "{src,tests}/**/*.ts",
|
|
18
|
+
"thresholds": {
|
|
19
|
+
"high": 80,
|
|
20
|
+
"low": 60,
|
|
21
|
+
"break": 80
|
|
22
|
+
},
|
|
23
|
+
"reporters": ["clear-text", "html"],
|
|
24
|
+
"timeoutMS": 5000,
|
|
25
|
+
"concurrency": 2
|
|
26
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type AccountStore,
|
|
4
|
+
InMemoryAccountStore,
|
|
5
|
+
type SaslPayload,
|
|
6
|
+
type SaslResult,
|
|
7
|
+
} from '../src/ports';
|
|
8
|
+
|
|
9
|
+
/** Convenience: a PLAIN payload. */
|
|
10
|
+
function plain(username: string, password: string): SaslPayload {
|
|
11
|
+
return { kind: 'PLAIN', username, password };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('InMemoryAccountStore — PLAIN', () => {
|
|
15
|
+
it('implements the AccountStore port', () => {
|
|
16
|
+
const store: AccountStore = new InMemoryAccountStore([
|
|
17
|
+
{ username: 'alice', password: 'secret' },
|
|
18
|
+
]);
|
|
19
|
+
expect(store).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
23
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
24
|
+
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
25
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a wrong password', () => {
|
|
29
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
30
|
+
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
31
|
+
expect(out.ok).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects an unknown username', () => {
|
|
35
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
36
|
+
const out = store.verify('PLAIN', plain('bob', 'secret'));
|
|
37
|
+
expect(out.ok).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('matches the first of several configured accounts', () => {
|
|
41
|
+
const store = new InMemoryAccountStore([
|
|
42
|
+
{ username: 'alice', password: 'a-secret' },
|
|
43
|
+
{ username: 'bob', password: 'b-secret' },
|
|
44
|
+
]);
|
|
45
|
+
expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual<SaslResult>({
|
|
46
|
+
ok: true,
|
|
47
|
+
account: 'bob',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects when constructed with an empty account list', () => {
|
|
52
|
+
const store = new InMemoryAccountStore([]);
|
|
53
|
+
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
54
|
+
expect(out.ok).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not confuse credentials across accounts (no password cross-match)', () => {
|
|
58
|
+
const store = new InMemoryAccountStore([
|
|
59
|
+
{ username: 'alice', password: 'a-secret' },
|
|
60
|
+
{ username: 'bob', password: 'b-secret' },
|
|
61
|
+
]);
|
|
62
|
+
// bob's password with alice's username must fail.
|
|
63
|
+
expect(store.verify('PLAIN', plain('alice', 'b-secret')).ok).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('InMemoryAccountStore — mechanism handling', () => {
|
|
68
|
+
it('rejects any non-PLAIN mechanism with a failure result', () => {
|
|
69
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
70
|
+
const out = store.verify('EXTERNAL', { kind: 'RAW', data: 'cert-subject' });
|
|
71
|
+
expect(out.ok).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats the mechanism name case-insensitively for PLAIN', () => {
|
|
75
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
76
|
+
const out = store.verify('plain', plain('alice', 'secret'));
|
|
77
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('InMemoryAccountStore — failure reason', () => {
|
|
82
|
+
it('returns a human-readable reason on failure', () => {
|
|
83
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
84
|
+
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
85
|
+
if (out.ok) throw new Error('expected failure');
|
|
86
|
+
expect(out.reason.length).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the per-IP / per-user connection admission gate.
|
|
3
|
+
*
|
|
4
|
+
* These tests cover the pure decision helpers in `admission.ts`. The
|
|
5
|
+
* runtime-level integration (tracking live connections per IP, sweeping
|
|
6
|
+
* them on disconnect) lives in the in-memory runtime and the CF / AWS
|
|
7
|
+
* adapters; this module asserts the policy those callers feed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
type AdmissionConfig,
|
|
13
|
+
type AdmissionDecision,
|
|
14
|
+
AdmissionStats,
|
|
15
|
+
decideAdmission,
|
|
16
|
+
} from '../src/admission';
|
|
17
|
+
import { FakeClock } from '../src/ports';
|
|
18
|
+
|
|
19
|
+
const BASE_CONFIG: AdmissionConfig = {
|
|
20
|
+
maxConnectionsPerIp: 3,
|
|
21
|
+
maxConnectionsPerUser: 2,
|
|
22
|
+
perIpConnectionRate: { max: 5, windowMs: 10_000 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Per-IP-cap-isolated config: rate limit + per-user cap set high enough to never bind. */
|
|
26
|
+
const IP_CAP_CONFIG: AdmissionConfig = {
|
|
27
|
+
maxConnectionsPerIp: 3,
|
|
28
|
+
maxConnectionsPerUser: 100,
|
|
29
|
+
perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Per-user-cap-isolated config: rate limit + per-IP cap set high enough to never bind. */
|
|
33
|
+
const USER_CAP_CONFIG: AdmissionConfig = {
|
|
34
|
+
maxConnectionsPerIp: 100,
|
|
35
|
+
maxConnectionsPerUser: 2,
|
|
36
|
+
perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Rate-limit-isolated config: per-IP + per-user caps set high enough to never bind. */
|
|
40
|
+
const RATE_CONFIG: AdmissionConfig = {
|
|
41
|
+
maxConnectionsPerIp: 100,
|
|
42
|
+
maxConnectionsPerUser: 100,
|
|
43
|
+
perIpConnectionRate: { max: 5, windowMs: 10_000 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function makeStats(clock: FakeClock): AdmissionStats {
|
|
47
|
+
return new AdmissionStats(clock);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('decideAdmission — per-IP connection cap', () => {
|
|
51
|
+
it('admits the first connection from an IP', () => {
|
|
52
|
+
const clock = new FakeClock(1_000);
|
|
53
|
+
const stats = makeStats(clock);
|
|
54
|
+
const decision = decideAdmission('1.2.3.4', 'alice', stats, IP_CAP_CONFIG);
|
|
55
|
+
expect(decision.ok).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('admits connections up to the per-IP cap', () => {
|
|
59
|
+
const clock = new FakeClock(1_000);
|
|
60
|
+
const stats = makeStats(clock);
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
const d = decideAdmission('1.2.3.4', `user${i}`, stats, IP_CAP_CONFIG);
|
|
63
|
+
expect(d.ok).toBe(true);
|
|
64
|
+
if (d.ok) stats.recordAdmission('1.2.3.4', `user${i}`, d.recordId);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a connection that would exceed the per-IP cap', () => {
|
|
69
|
+
const clock = new FakeClock(1_000);
|
|
70
|
+
const stats = makeStats(clock);
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
const d = decideAdmission('1.2.3.4', `user${i}`, stats, IP_CAP_CONFIG);
|
|
73
|
+
if (d.ok) stats.recordAdmission('1.2.3.4', `user${i}`, d.recordId);
|
|
74
|
+
}
|
|
75
|
+
const decision = decideAdmission('1.2.3.4', 'user3', stats, IP_CAP_CONFIG);
|
|
76
|
+
expect(decision.ok).toBe(false);
|
|
77
|
+
if (!decision.ok) {
|
|
78
|
+
expect(decision.reason).toBe('max_connections_per_ip');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('counts per-IP independently for different IPs', () => {
|
|
83
|
+
const clock = new FakeClock(1_000);
|
|
84
|
+
const stats = makeStats(clock);
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
const d = decideAdmission('1.1.1.1', `a${i}`, stats, IP_CAP_CONFIG);
|
|
87
|
+
if (d.ok) stats.recordAdmission('1.1.1.1', `a${i}`, d.recordId);
|
|
88
|
+
}
|
|
89
|
+
// A different IP still has its full budget.
|
|
90
|
+
const d = decideAdmission('2.2.2.2', 'b', stats, IP_CAP_CONFIG);
|
|
91
|
+
expect(d.ok).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('decideAdmission — per-user connection cap', () => {
|
|
96
|
+
it('rejects a connection that would exceed the per-user cap', () => {
|
|
97
|
+
const clock = new FakeClock(1_000);
|
|
98
|
+
const stats = makeStats(clock);
|
|
99
|
+
for (let i = 0; i < 2; i++) {
|
|
100
|
+
const d = decideAdmission(`10.0.0.${i}`, 'alice', stats, USER_CAP_CONFIG);
|
|
101
|
+
if (d.ok) stats.recordAdmission(`10.0.0.${i}`, 'alice', d.recordId);
|
|
102
|
+
}
|
|
103
|
+
const decision = decideAdmission('10.0.0.5', 'alice', stats, USER_CAP_CONFIG);
|
|
104
|
+
expect(decision.ok).toBe(false);
|
|
105
|
+
if (!decision.ok) {
|
|
106
|
+
expect(decision.reason).toBe('max_connections_per_user');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats an undefined user as IP-keyed (does not collapse anonymous connections together across IPs)', () => {
|
|
111
|
+
const clock = new FakeClock(1_000);
|
|
112
|
+
const stats = makeStats(clock);
|
|
113
|
+
// Anonymous connection from IP #1.
|
|
114
|
+
const d1 = decideAdmission('1.1.1.1', undefined, stats, USER_CAP_CONFIG);
|
|
115
|
+
expect(d1.ok).toBe(true);
|
|
116
|
+
if (d1.ok) stats.recordAdmission('1.1.1.1', undefined, d1.recordId);
|
|
117
|
+
// Anonymous connection from a different IP is admitted (per-user cap is
|
|
118
|
+
// scoped to the IP when no SASL identity is available).
|
|
119
|
+
const d2 = decideAdmission('2.2.2.2', undefined, stats, USER_CAP_CONFIG);
|
|
120
|
+
expect(d2.ok).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('decideAdmission — per-IP connection-rate limit', () => {
|
|
125
|
+
it('admits bursts within the rate cap', () => {
|
|
126
|
+
const clock = new FakeClock(1_000);
|
|
127
|
+
const stats = makeStats(clock);
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
|
|
130
|
+
expect(d.ok).toBe(true);
|
|
131
|
+
if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects a connection exceeding the rate cap inside the window', () => {
|
|
136
|
+
const clock = new FakeClock(1_000);
|
|
137
|
+
const stats = makeStats(clock);
|
|
138
|
+
// Use distinct users so per-user cap doesn't kick in first.
|
|
139
|
+
for (let i = 0; i < 5; i++) {
|
|
140
|
+
const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
|
|
141
|
+
if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
|
|
142
|
+
}
|
|
143
|
+
const decision = decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG);
|
|
144
|
+
expect(decision.ok).toBe(false);
|
|
145
|
+
if (!decision.ok) {
|
|
146
|
+
expect(decision.reason).toBe('rate_limited_per_ip');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('admits again once the window has elapsed', () => {
|
|
151
|
+
const clock = new FakeClock(1_000);
|
|
152
|
+
const stats = makeStats(clock);
|
|
153
|
+
for (let i = 0; i < 5; i++) {
|
|
154
|
+
const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
|
|
155
|
+
if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
|
|
156
|
+
}
|
|
157
|
+
// Within the window: rejected.
|
|
158
|
+
expect(decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG).ok).toBe(false);
|
|
159
|
+
// Advance past the window: the stale rate entries are dropped.
|
|
160
|
+
clock.advance(10_001);
|
|
161
|
+
const after = decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG);
|
|
162
|
+
expect(after.ok).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('decideAdmission — combined precedence', () => {
|
|
167
|
+
it('checks the rate limit before the per-IP cap', () => {
|
|
168
|
+
// Tight rate cap (2 in 10s) and a generous per-IP cap (100).
|
|
169
|
+
const cfg: AdmissionConfig = {
|
|
170
|
+
maxConnectionsPerIp: 100,
|
|
171
|
+
maxConnectionsPerUser: 100,
|
|
172
|
+
perIpConnectionRate: { max: 2, windowMs: 10_000 },
|
|
173
|
+
};
|
|
174
|
+
const clock = new FakeClock(1_000);
|
|
175
|
+
const stats = makeStats(clock);
|
|
176
|
+
for (let i = 0; i < 2; i++) {
|
|
177
|
+
const d = decideAdmission('5.5.5.5', `u${i}`, stats, cfg);
|
|
178
|
+
if (d.ok) stats.recordAdmission('5.5.5.5', `u${i}`, d.recordId);
|
|
179
|
+
}
|
|
180
|
+
const decision = decideAdmission('5.5.5.5', 'u2', stats, cfg);
|
|
181
|
+
expect(decision.ok).toBe(false);
|
|
182
|
+
if (!decision.ok) {
|
|
183
|
+
expect(decision.reason).toBe('rate_limited_per_ip');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('AdmissionStats — release', () => {
|
|
189
|
+
it('decrements per-IP and per-user counts when a connection is released', () => {
|
|
190
|
+
const clock = new FakeClock(1_000);
|
|
191
|
+
const stats = makeStats(clock);
|
|
192
|
+
const d1 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
|
|
193
|
+
expect(d1.ok).toBe(true);
|
|
194
|
+
if (!d1.ok) throw new Error('precondition: d1 should admit');
|
|
195
|
+
stats.recordAdmission('1.2.3.4', 'alice', d1.recordId);
|
|
196
|
+
const d2 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
|
|
197
|
+
expect(d2.ok).toBe(true);
|
|
198
|
+
if (!d2.ok) throw new Error('precondition: d2 should admit');
|
|
199
|
+
stats.recordAdmission('1.2.3.4', 'alice', d2.recordId);
|
|
200
|
+
// At cap now.
|
|
201
|
+
const d3 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
|
|
202
|
+
expect(d3.ok).toBe(false);
|
|
203
|
+
// Release one; next attempt succeeds.
|
|
204
|
+
stats.release(d1.recordId);
|
|
205
|
+
const d4 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
|
|
206
|
+
expect(d4.ok).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('releasing an unknown record id is a no-op', () => {
|
|
210
|
+
const clock = new FakeClock(1_000);
|
|
211
|
+
const stats = makeStats(clock);
|
|
212
|
+
expect(() => stats.release('unknown')).not.toThrow();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('AdmissionStats — determinism', () => {
|
|
217
|
+
it('returns identical decisions for identical Clock + input streams', () => {
|
|
218
|
+
const run = (): AdmissionDecision => {
|
|
219
|
+
const clock = new FakeClock(1_000);
|
|
220
|
+
const stats = makeStats(clock);
|
|
221
|
+
for (let i = 0; i < 4; i++) {
|
|
222
|
+
const d = decideAdmission('1.1.1.1', `u${i}`, stats, BASE_CONFIG);
|
|
223
|
+
if (d.ok) stats.recordAdmission('1.1.1.1', `u${i}`, d.recordId);
|
|
224
|
+
}
|
|
225
|
+
return decideAdmission('1.1.1.1', 'u4', stats, BASE_CONFIG);
|
|
226
|
+
};
|
|
227
|
+
expect(run()).toEqual(run());
|
|
228
|
+
});
|
|
229
|
+
});
|