serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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
|
@@ -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,24 @@
|
|
|
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
|
+
|
|
14
28
|
/**
|
|
15
29
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
16
30
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
@@ -21,6 +35,24 @@ import type { ConnectionState } from './state/connection.js';
|
|
|
21
35
|
export interface ServerConfig {
|
|
22
36
|
serverName: string;
|
|
23
37
|
networkName: string;
|
|
38
|
+
/**
|
|
39
|
+
* Server password gate. Empty string or undefined disables enforcement.
|
|
40
|
+
* When set, registration refuses to complete until the connection
|
|
41
|
+
* supplies the matching value via `PASS`.
|
|
42
|
+
*
|
|
43
|
+
* Typed `string | undefined` (rather than just `string`) so the field is
|
|
44
|
+
* structurally compatible with Zod's `ServerConfigSchema` output under
|
|
45
|
+
* `exactOptionalPropertyTypes: true` — `.optional()` yields `T | undefined`.
|
|
46
|
+
*/
|
|
47
|
+
readonly serverPassword?: string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Cloaking config. Undefined when cloaking is disabled (the default);
|
|
50
|
+
* reducers consult `enabled` + `secret` to compute the visible host.
|
|
51
|
+
*
|
|
52
|
+
* Typed `CloakingConfig | undefined` to mirror Zod's `.optional()` output
|
|
53
|
+
* (see `serverPassword` note).
|
|
54
|
+
*/
|
|
55
|
+
readonly cloaking?: CloakingConfig | undefined;
|
|
24
56
|
maxChannelsPerUser: number;
|
|
25
57
|
maxTargetsPerCommand: number;
|
|
26
58
|
maxListEntries: number;
|
|
@@ -28,6 +60,24 @@ export interface ServerConfig {
|
|
|
28
60
|
channelLen: number;
|
|
29
61
|
topicLen: number;
|
|
30
62
|
quitMessage: string;
|
|
63
|
+
/**
|
|
64
|
+
* Per-connection message-rate flood control. `undefined` → the actor
|
|
65
|
+
* applies the documented default bucket; `null` → flood control disabled;
|
|
66
|
+
* an object → use those thresholds. The optional `commandCost` is wired
|
|
67
|
+
* in code when absent (control-plane commands are exempt by default) — it
|
|
68
|
+
* cannot be serialized from a config store.
|
|
69
|
+
*/
|
|
70
|
+
readonly floodControl?: FloodControlConfig | null | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Maximum number of back-messages replayed on JOIN when the joiner has
|
|
73
|
+
* negotiated `draft/chathistory`. Defaults to 50 per the spec's
|
|
74
|
+
* recommendation when omitted. Only consulted when a {@link MessageStore}
|
|
75
|
+
* is bound.
|
|
76
|
+
*
|
|
77
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
78
|
+
* `exactOptionalPropertyTypes: true`.
|
|
79
|
+
*/
|
|
80
|
+
chatHistoryPlaybackLimit?: number | undefined;
|
|
31
81
|
}
|
|
32
82
|
|
|
33
83
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -41,6 +91,15 @@ export interface Ctx {
|
|
|
41
91
|
readonly ids: IdFactory;
|
|
42
92
|
/** Pre-loaded MOTD source for the `MOTD` reducer. */
|
|
43
93
|
readonly motd: MotdProvider;
|
|
94
|
+
/** SASL account verification source (absent when no accounts are configured). */
|
|
95
|
+
readonly accounts?: AccountStore;
|
|
96
|
+
/**
|
|
97
|
+
* Chat-history persistence source (absent when chathistory playback is
|
|
98
|
+
* disabled for this deployment). When present, the PRIVMSG/NOTICE channel
|
|
99
|
+
* reducers record every accepted message and JOIN replays the backlog for
|
|
100
|
+
* cap-enabled joiners.
|
|
101
|
+
*/
|
|
102
|
+
readonly messages?: MessageStore;
|
|
44
103
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
45
104
|
readonly connection: ConnectionState;
|
|
46
105
|
/** Convenience: `connection.id`. */
|
|
@@ -65,12 +124,14 @@ export interface BuildCtxOptions {
|
|
|
65
124
|
clock: Clock;
|
|
66
125
|
ids: IdFactory;
|
|
67
126
|
motd: MotdProvider;
|
|
127
|
+
accounts?: AccountStore;
|
|
128
|
+
messages?: MessageStore;
|
|
68
129
|
connection: ConnectionState;
|
|
69
130
|
}
|
|
70
131
|
|
|
71
132
|
/** Constructs a {@link Ctx} from the required parts. */
|
|
72
133
|
export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
73
|
-
|
|
134
|
+
const ctx: Ctx = {
|
|
74
135
|
serverConfig: opts.serverConfig,
|
|
75
136
|
serverName: opts.serverConfig.serverName,
|
|
76
137
|
networkName: opts.serverConfig.networkName,
|
|
@@ -79,5 +140,8 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
79
140
|
motd: opts.motd,
|
|
80
141
|
connection: opts.connection,
|
|
81
142
|
connId: opts.connection.id,
|
|
143
|
+
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
144
|
+
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
82
145
|
};
|
|
146
|
+
return ctx;
|
|
83
147
|
}
|
|
@@ -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,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
|
+
});
|
|
@@ -18,6 +18,7 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
18
18
|
'away-notify',
|
|
19
19
|
'batch',
|
|
20
20
|
'chghost',
|
|
21
|
+
'draft/chathistory',
|
|
21
22
|
'echo-message',
|
|
22
23
|
'extended-join',
|
|
23
24
|
'invite-notify',
|
|
@@ -73,6 +74,10 @@ describe('isSupportedCap', () => {
|
|
|
73
74
|
expect(isSupportedCap('safelist')).toBe(true);
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
it('returns true for draft/chathistory (bare name, no value)', () => {
|
|
78
|
+
expect(isSupportedCap('draft/chathistory')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
76
81
|
it('returns false for an unknown cap name', () => {
|
|
77
82
|
expect(isSupportedCap('does-not-exist')).toBe(false);
|
|
78
83
|
});
|
|
@@ -111,6 +116,23 @@ describe('getLsString', () => {
|
|
|
111
116
|
});
|
|
112
117
|
});
|
|
113
118
|
|
|
119
|
+
describe('draft/chathistory advertisement', () => {
|
|
120
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
121
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/chathistory');
|
|
122
|
+
expect(cap).toBeDefined();
|
|
123
|
+
expect(cap?.value).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
127
|
+
expect(getLsString()).toContain('draft/chathistory');
|
|
128
|
+
expect(getLsString()).not.toContain('draft/chathistory=');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
132
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/chathistory')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
114
136
|
describe('splitCapsForWire', () => {
|
|
115
137
|
it('returns a single chunk when everything fits', () => {
|
|
116
138
|
const tokens = ['a', 'b', 'c'];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { cloakHost } from '../src/cloak';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// cloakHost — basic deterministic cloaking
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
describe('cloakHost — determinism', () => {
|
|
9
|
+
it('returns the same cloak for the same IP + secret', () => {
|
|
10
|
+
const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
11
|
+
const b = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
12
|
+
expect(a).toBe(b);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns different cloaks for different IPs with the same secret', () => {
|
|
16
|
+
const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
17
|
+
const b = cloakHost('198.51.100.7', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
18
|
+
expect(a).not.toBe(b);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns different cloaks for the same IP with different secrets', () => {
|
|
22
|
+
const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
23
|
+
const b = cloakHost('203.0.113.5', { enabled: true, secret: 'other' }, 'irc.example.com');
|
|
24
|
+
expect(a).not.toBe(b);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// cloakHost — format
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
describe('cloakHost — format', () => {
|
|
33
|
+
it('produces a cloak ending in the configured suffix', () => {
|
|
34
|
+
const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
35
|
+
expect(cloak.endsWith('.irc.example.com')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('produces a cloak whose leading segment is a stable hex-encoded hash', () => {
|
|
39
|
+
const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
40
|
+
// Strip the suffix, leaving just the hash segment.
|
|
41
|
+
const segment = cloak.slice(0, cloak.indexOf('.'));
|
|
42
|
+
expect(segment).toMatch(/^[a-z0-9]+$/u);
|
|
43
|
+
expect(segment.length).toBeGreaterThanOrEqual(8);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('produces a cloak no longer than 63 bytes per label (DNS limit)', () => {
|
|
47
|
+
const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
48
|
+
for (const label of cloak.split('.')) {
|
|
49
|
+
expect(label.length).toBeLessThanOrEqual(63);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// cloakHost — disabled
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
describe('cloakHost — disabled', () => {
|
|
59
|
+
it('returns the original host when cloaking is disabled', () => {
|
|
60
|
+
const cloak = cloakHost('203.0.113.5', { enabled: false, secret: 's3cret' }, 'irc.example.com');
|
|
61
|
+
expect(cloak).toBe('203.0.113.5');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// cloakHost — IPv6
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
describe('cloakHost — IPv6', () => {
|
|
70
|
+
it('cloaks an IPv6 address the same way as an IPv4 address', () => {
|
|
71
|
+
const cloak = cloakHost('2001:db8::1', { enabled: true, secret: 's3cret' }, 'irc.example.com');
|
|
72
|
+
expect(cloak.endsWith('.irc.example.com')).toBe(true);
|
|
73
|
+
// Re-cloaking deterministically yields the same value.
|
|
74
|
+
expect(cloakHost('2001:db8::1', { enabled: true, secret: 's3cret' }, 'irc.example.com')).toBe(
|
|
75
|
+
cloak,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|