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
|
@@ -34,7 +34,12 @@ import {
|
|
|
34
34
|
type Clock,
|
|
35
35
|
type ConnId,
|
|
36
36
|
type ConnectionState,
|
|
37
|
+
ConsoleLogger,
|
|
37
38
|
type IdFactory,
|
|
39
|
+
InMemoryMessageStore,
|
|
40
|
+
LogLevel,
|
|
41
|
+
type Logger,
|
|
42
|
+
type MessageStore,
|
|
38
43
|
type MotdProvider,
|
|
39
44
|
type RawLine,
|
|
40
45
|
type ServerConfig,
|
|
@@ -90,6 +95,15 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
90
95
|
* should seed `createdAt` with its receive time.
|
|
91
96
|
*/
|
|
92
97
|
private channelAccess: PassthroughChannelAccess | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Per-connection chat-history store backing `draft/chathistory`
|
|
100
|
+
* playback. The minimum-viable in-worker ring buffer is scoped to this
|
|
101
|
+
* connection: messages this connection sends are recorded and replayable
|
|
102
|
+
* by the same connection. A Durable-Object-backed persistent variant
|
|
103
|
+
* (shared across connections, keyed by channel) is a documented
|
|
104
|
+
* follow-up. Lazily constructed so the first frame seeds it.
|
|
105
|
+
*/
|
|
106
|
+
private messages: MessageStore | undefined;
|
|
93
107
|
private readonly clock: Clock = SystemClock;
|
|
94
108
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
95
109
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
@@ -293,6 +307,10 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
293
307
|
if (this.channelAccess === undefined) {
|
|
294
308
|
this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
|
|
295
309
|
}
|
|
310
|
+
// Workers Observability ingests `console.*` directly.
|
|
311
|
+
// Bound `connectionId` so every record the actor emits is filterable
|
|
312
|
+
// per-connection in the Workers dashboard.
|
|
313
|
+
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
296
314
|
return new ConnectionActor({
|
|
297
315
|
state,
|
|
298
316
|
runtime,
|
|
@@ -301,6 +319,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
301
319
|
clock: this.clock,
|
|
302
320
|
ids: this.ids,
|
|
303
321
|
motd,
|
|
322
|
+
messages: this.messageStore(),
|
|
323
|
+
logger,
|
|
304
324
|
});
|
|
305
325
|
}
|
|
306
326
|
|
|
@@ -351,6 +371,19 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
351
371
|
return { lines: () => raw.split('\n') };
|
|
352
372
|
}
|
|
353
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Lazily constructs the per-connection chat-history store. The ring
|
|
376
|
+
* buffer default cap matches the shared `InMemoryMessageStore`; a
|
|
377
|
+
* future persistent variant (DO/KV-backed, keyed by channel) will
|
|
378
|
+
* replace this without touching the actor wiring.
|
|
379
|
+
*/
|
|
380
|
+
private messageStore(): MessageStore {
|
|
381
|
+
if (this.messages === undefined) {
|
|
382
|
+
this.messages = new InMemoryMessageStore();
|
|
383
|
+
}
|
|
384
|
+
return this.messages;
|
|
385
|
+
}
|
|
386
|
+
|
|
354
387
|
// -------------------------------------------------------------------------
|
|
355
388
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
356
389
|
// -------------------------------------------------------------------------
|
|
@@ -394,6 +427,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
394
427
|
clock: this.clock,
|
|
395
428
|
ids: this.ids,
|
|
396
429
|
motd: this.motdProvider(),
|
|
430
|
+
messages: this.messageStore(),
|
|
397
431
|
});
|
|
398
432
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
399
433
|
await this.releaseAndPersist(state);
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
IrcHarness,
|
|
21
21
|
IrcHarnessFactory,
|
|
22
22
|
SpawnClientOptions,
|
|
23
|
-
} from '
|
|
23
|
+
} from '@serverless-ircd/irc-test-support';
|
|
24
24
|
|
|
25
25
|
/** Default tick for the polling waitFor* implementations (ms). */
|
|
26
26
|
const POLL_MS = 10;
|
|
@@ -57,7 +57,7 @@ export interface CfHarnessEnv {
|
|
|
57
57
|
*
|
|
58
58
|
* ```ts
|
|
59
59
|
* import { env } from 'cloudflare:test';
|
|
60
|
-
* import { runIrcScenarios } from '
|
|
60
|
+
* import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
61
61
|
* import { makeCfHarnessFactory } from './cf-harness';
|
|
62
62
|
*
|
|
63
63
|
* runIrcScenarios([makeCfHarnessFactory({ env })]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parametrized IRC scenarios against the Cloudflare runtime — 036.
|
|
3
3
|
*
|
|
4
|
-
* Reuses the scenario runner from
|
|
4
|
+
* Reuses the scenario runner from `@serverless-ircd/irc-test-support`
|
|
5
5
|
* (032) and registers the CF harness factory built on top of the
|
|
6
6
|
* real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
|
|
7
7
|
* scenario spawns one or more real WebSockets and drives the full
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { env, reset } from 'cloudflare:test';
|
|
29
|
+
import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
29
30
|
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
30
31
|
import { makeCfHarnessFactory } from './cf-harness';
|
|
31
|
-
import { runIrcScenarios } from './integration/scenarios.js';
|
|
32
32
|
|
|
33
33
|
declare global {
|
|
34
34
|
namespace Cloudflare {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading for the cf-adapter.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the shared `ServerConfigSchema` (from `irc-core`) is consumed
|
|
5
|
+
* by the Cloudflare adapter — both adapters (CF + AWS) build the same
|
|
6
|
+
* shape from their platform-native sources.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { loadServerConfigFromCfEnv } from '../src/config-loader';
|
|
11
|
+
|
|
12
|
+
interface CfEnvLike {
|
|
13
|
+
SERVER_NAME?: string;
|
|
14
|
+
NETWORK_NAME?: string;
|
|
15
|
+
MOTD_LINES?: string;
|
|
16
|
+
MAX_CLIENTS?: string;
|
|
17
|
+
CHANNEL_PREFIXES?: string;
|
|
18
|
+
OPER_USER?: string;
|
|
19
|
+
OPER_PASSWORD?: string;
|
|
20
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
21
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
22
|
+
NICK_LEN?: string;
|
|
23
|
+
CHANNEL_LEN?: string;
|
|
24
|
+
TOPIC_LEN?: string;
|
|
25
|
+
MAX_LIST_ENTRIES?: string;
|
|
26
|
+
QUIT_MESSAGE?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function baseEnv(): CfEnvLike {
|
|
30
|
+
return { SERVER_NAME: 'irc.cf.example.com', NETWORK_NAME: 'CfNet' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
34
|
+
it('returns a parsed config derived from the minimal required vars', () => {
|
|
35
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
36
|
+
expect(cfg.serverName).toBe('irc.cf.example.com');
|
|
37
|
+
expect(cfg.networkName).toBe('CfNet');
|
|
38
|
+
// Defaults still apply.
|
|
39
|
+
expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
|
|
40
|
+
expect(cfg.maxClients).toBeGreaterThan(0);
|
|
41
|
+
expect(cfg.operCreds).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('splits MOTD_LINES on newlines into motdLines', () => {
|
|
45
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
46
|
+
...baseEnv(),
|
|
47
|
+
MOTD_LINES: 'line 1\nline 2\nline 3',
|
|
48
|
+
});
|
|
49
|
+
expect(cfg.motdLines).toEqual(['line 1', 'line 2', 'line 3']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('treats an undefined MOTD_LINES as the default empty MOTD', () => {
|
|
53
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
54
|
+
expect(cfg.motdLines).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('parses MAX_CLIENTS as an integer', () => {
|
|
58
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: '4242' });
|
|
59
|
+
expect(cfg.maxClients).toBe(4242);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('parses CHANNEL_PREFIXES verbatim', () => {
|
|
63
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
64
|
+
...baseEnv(),
|
|
65
|
+
CHANNEL_PREFIXES: '#&+',
|
|
66
|
+
});
|
|
67
|
+
expect(cfg.channelPrefixes).toBe('#&+');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('builds operCreds from OPER_USER + OPER_PASSWORD when both set', () => {
|
|
71
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
72
|
+
...baseEnv(),
|
|
73
|
+
OPER_USER: 'admin',
|
|
74
|
+
OPER_PASSWORD: 'hunter2',
|
|
75
|
+
});
|
|
76
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('parses numeric limit overrides from string env vars', () => {
|
|
80
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
81
|
+
...baseEnv(),
|
|
82
|
+
MAX_CHANNELS_PER_USER: '12',
|
|
83
|
+
MAX_TARGETS_PER_COMMAND: '3',
|
|
84
|
+
NICK_LEN: '20',
|
|
85
|
+
CHANNEL_LEN: '40',
|
|
86
|
+
TOPIC_LEN: '300',
|
|
87
|
+
MAX_LIST_ENTRIES: '7',
|
|
88
|
+
});
|
|
89
|
+
expect(cfg.maxChannelsPerUser).toBe(12);
|
|
90
|
+
expect(cfg.maxTargetsPerCommand).toBe(3);
|
|
91
|
+
expect(cfg.nickLen).toBe(20);
|
|
92
|
+
expect(cfg.channelLen).toBe(40);
|
|
93
|
+
expect(cfg.topicLen).toBe(300);
|
|
94
|
+
expect(cfg.maxListEntries).toBe(7);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('honours a custom QUIT_MESSAGE', () => {
|
|
98
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
99
|
+
...baseEnv(),
|
|
100
|
+
QUIT_MESSAGE: 'Bye from CF',
|
|
101
|
+
});
|
|
102
|
+
expect(cfg.quitMessage).toBe('Bye from CF');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('loadServerConfigFromCfEnv — failure modes', () => {
|
|
107
|
+
it('throws when SERVER_NAME is missing', () => {
|
|
108
|
+
expect(() => loadServerConfigFromCfEnv({ NETWORK_NAME: 'CfNet' })).toThrowError(/serverName/u);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws when NETWORK_NAME is missing', () => {
|
|
112
|
+
expect(() => loadServerConfigFromCfEnv({ SERVER_NAME: 's' })).toThrowError(/networkName/u);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws when MAX_CLIENTS is non-numeric', () => {
|
|
116
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: 'lots' })).toThrowError(
|
|
117
|
+
/maxClients/u,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws when OPER_USER is set but OPER_PASSWORD is missing', () => {
|
|
122
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_USER: 'admin' })).toThrowError(
|
|
123
|
+
/password/u,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws when OPER_PASSWORD is set but OPER_USER is missing', () => {
|
|
128
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_PASSWORD: 'p' })).toThrowError(
|
|
129
|
+
/user/u,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('aggregates multiple errors into a single readable Error', () => {
|
|
134
|
+
try {
|
|
135
|
+
loadServerConfigFromCfEnv({
|
|
136
|
+
MAX_CLIENTS: 'oops',
|
|
137
|
+
OPER_USER: 'admin',
|
|
138
|
+
});
|
|
139
|
+
expect.fail('expected throw');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
expect(err).toBeInstanceOf(Error);
|
|
142
|
+
const msg = (err as Error).message;
|
|
143
|
+
expect(msg).toMatch(/invalid server config/iu);
|
|
144
|
+
// Both bad fields are surfaced.
|
|
145
|
+
expect(msg).toMatch(/serverName/u);
|
|
146
|
+
expect(msg).toMatch(/maxClients/u);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -123,6 +123,23 @@ async function register(client: IrcWsClient, nick: string): Promise<void> {
|
|
|
123
123
|
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Negotiates the chathistory cap set then registers. Mirrors the
|
|
128
|
+
* pre-registration `CAP REQ` → `NICK`/`USER` → `CAP END` flow a real
|
|
129
|
+
* client uses.
|
|
130
|
+
*/
|
|
131
|
+
async function registerWithChathistory(client: IrcWsClient, nick: string): Promise<void> {
|
|
132
|
+
// `echo-message` is included so tests can deterministically wait for a
|
|
133
|
+
// PRIVMSG to be recorded before issuing a follow-up CHATHISTORY query
|
|
134
|
+
// (otherwise the two async webSocketMessage handlers race).
|
|
135
|
+
client.send('CAP REQ :draft/chathistory server-time message-tags batch echo-message\r\n');
|
|
136
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :/.test(l));
|
|
137
|
+
client.send(`NICK ${nick}\r\n`);
|
|
138
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
139
|
+
client.send('CAP END\r\n');
|
|
140
|
+
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* Forces the ConnectionDO to drop its in-memory cache of the
|
|
128
145
|
* {@link ConnectionState}, simulating wake-from-hibernation. The next
|
|
@@ -324,6 +341,31 @@ describe('ConnectionDO — alarms', () => {
|
|
|
324
341
|
});
|
|
325
342
|
});
|
|
326
343
|
|
|
344
|
+
describe('ConnectionDO — chathistory end-to-end', () => {
|
|
345
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
346
|
+
const client = await connect('conn-chathist-1');
|
|
347
|
+
await registerWithChathistory(client, 'cf-alice');
|
|
348
|
+
|
|
349
|
+
client.send('JOIN #cf-chathist\r\n');
|
|
350
|
+
await client.waitForMessage((l) => l.includes(' 366 ') && l.includes('#cf-chathist'));
|
|
351
|
+
|
|
352
|
+
client.send('PRIVMSG #cf-chathist :recorded via cf\r\n');
|
|
353
|
+
// Wait for the echo (echo-message cap) so the PRIVMSG is recorded
|
|
354
|
+
// before the CHATHISTORY query arrives as a separate frame.
|
|
355
|
+
await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
|
|
356
|
+
|
|
357
|
+
client.send('CHATHISTORY LATEST #cf-chathist * 10\r\n');
|
|
358
|
+
const batchOpen = await client.waitForMessage((l) =>
|
|
359
|
+
/^BATCH \+\S+ chathistory #cf-chathist/.test(l),
|
|
360
|
+
);
|
|
361
|
+
expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #cf-chathist/);
|
|
362
|
+
|
|
363
|
+
await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
|
|
364
|
+
|
|
365
|
+
client.ws.close();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
327
369
|
// ---------------------------------------------------------------------------
|
|
328
370
|
// End of file
|
|
329
371
|
// ---------------------------------------------------------------------------
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
type AdmissionConfig,
|
|
17
|
+
type AdmissionDecision,
|
|
18
|
+
AdmissionStats,
|
|
16
19
|
type ChanName,
|
|
17
20
|
type ChanSnapshot,
|
|
18
21
|
type ChannelDelta,
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
type RawLine,
|
|
26
29
|
applyChannelDelta as applyDelta,
|
|
27
30
|
createChannel,
|
|
31
|
+
decideAdmission,
|
|
28
32
|
toChannelSnapshot,
|
|
29
33
|
toSnapshot as toConnSnapshot,
|
|
30
34
|
} from '@serverless-ircd/irc-core';
|
|
@@ -41,20 +45,93 @@ export interface ConnectionHandlers {
|
|
|
41
45
|
interface TrackedConnection {
|
|
42
46
|
state: ConnectionState;
|
|
43
47
|
handlers: ConnectionHandlers;
|
|
48
|
+
/**
|
|
49
|
+
* Optional admission record id paired with this connection at register
|
|
50
|
+
* time. When set, {@link InMemoryRuntime.unregisterConnection} releases
|
|
51
|
+
* it so per-IP / per-user connection counters stay accurate.
|
|
52
|
+
*/
|
|
53
|
+
admissionRecordId?: string;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
function lower(s: string): string {
|
|
47
57
|
return s.toLowerCase();
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
let disabledRecordCounter = 0;
|
|
61
|
+
/**
|
|
62
|
+
* Returns a unique record id for the "admission disabled" path. The id is
|
|
63
|
+
* never consulted (no {@link AdmissionStats} exists), but emitting one
|
|
64
|
+
* keeps {@link admitConnection}'s return shape uniform.
|
|
65
|
+
*/
|
|
66
|
+
function _disabledRecordId(): string {
|
|
67
|
+
disabledRecordCounter += 1;
|
|
68
|
+
return `disabled-${disabledRecordCounter}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InMemoryRuntimeOptions {
|
|
72
|
+
clock: Clock;
|
|
73
|
+
/**
|
|
74
|
+
* Connection-admission policy. When omitted the admission gate is
|
|
75
|
+
* disabled — every {@link InMemoryRuntime.admitConnection} call returns
|
|
76
|
+
* an admit decision. Adapters that want to enforce per-IP / per-user /
|
|
77
|
+
* rate limits pass the same {@link AdmissionConfig} derived from the
|
|
78
|
+
* deployment's `ServerConfig`.
|
|
79
|
+
*/
|
|
80
|
+
readonly admission?: AdmissionConfig;
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
export class InMemoryRuntime implements IrcRuntime {
|
|
51
84
|
private readonly clock: Clock;
|
|
52
85
|
private readonly connections = new Map<ConnId, TrackedConnection>();
|
|
53
86
|
private readonly channels = new Map<string, ChannelState>();
|
|
54
87
|
private readonly nicks = new Map<string, ConnId>();
|
|
88
|
+
private readonly admissionStats: AdmissionStats | undefined;
|
|
89
|
+
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
55
90
|
|
|
56
|
-
constructor(opts:
|
|
91
|
+
constructor(opts: InMemoryRuntimeOptions) {
|
|
57
92
|
this.clock = opts.clock;
|
|
93
|
+
if (opts.admission !== undefined) {
|
|
94
|
+
this.admissionConfig = opts.admission;
|
|
95
|
+
this.admissionStats = new AdmissionStats(this.clock);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ------------------------------------------------------------------
|
|
100
|
+
// Admission gate (NOT part of the IrcRuntime port — adapter only)
|
|
101
|
+
// ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decides whether a new connection should be admitted under the
|
|
105
|
+
* configured {@link AdmissionConfig}. Returns `{ ok: true, recordId }`
|
|
106
|
+
* when the connection may proceed; the caller MUST then call
|
|
107
|
+
* {@link commitAdmission} exactly once with the returned recordId.
|
|
108
|
+
*
|
|
109
|
+
* Returns `{ ok: false, reason }` when the connection must be refused.
|
|
110
|
+
*
|
|
111
|
+
* When no admission policy was supplied at construction, this method
|
|
112
|
+
* unconditionally admits (the gate is disabled).
|
|
113
|
+
*/
|
|
114
|
+
admitConnection(ip: string, user: string | undefined): AdmissionDecision {
|
|
115
|
+
if (this.admissionConfig === undefined || this.admissionStats === undefined) {
|
|
116
|
+
return { ok: true, recordId: _disabledRecordId() };
|
|
117
|
+
}
|
|
118
|
+
return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Records an accepted admission. MUST be called exactly once per
|
|
123
|
+
* successful {@link admitConnection}. No-op when admission is disabled.
|
|
124
|
+
*/
|
|
125
|
+
commitAdmission(ip: string, user: string | undefined, recordId: string): void {
|
|
126
|
+
this.admissionStats?.recordAdmission(ip, user, recordId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Releases a previously-committed admission record. Safe to call with
|
|
131
|
+
* an unknown id. No-op when admission is disabled.
|
|
132
|
+
*/
|
|
133
|
+
releaseAdmission(recordId: string): void {
|
|
134
|
+
this.admissionStats?.release(recordId);
|
|
58
135
|
}
|
|
59
136
|
|
|
60
137
|
// ------------------------------------------------------------------
|
|
@@ -65,13 +142,31 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
65
142
|
* Tracks a connection and its transport handlers. The runtime keeps a
|
|
66
143
|
* reference to `state`; the actor may continue mutating it in place and
|
|
67
144
|
* the runtime will observe the changes via {@link getConnectionInfo}.
|
|
145
|
+
*
|
|
146
|
+
* @param admissionRecordId Optional record id returned by
|
|
147
|
+
* {@link admitConnection} + {@link commitAdmission}. When supplied,
|
|
148
|
+
* {@link unregisterConnection} releases it automatically so the
|
|
149
|
+
* per-IP / per-user connection counters stay accurate without the
|
|
150
|
+
* adapter remembering to call {@link releaseAdmission} itself.
|
|
68
151
|
*/
|
|
69
|
-
registerConnection(
|
|
70
|
-
|
|
152
|
+
registerConnection(
|
|
153
|
+
state: ConnectionState,
|
|
154
|
+
handlers: ConnectionHandlers,
|
|
155
|
+
admissionRecordId?: string,
|
|
156
|
+
): void {
|
|
157
|
+
const tracked: TrackedConnection =
|
|
158
|
+
admissionRecordId !== undefined
|
|
159
|
+
? { state, handlers, admissionRecordId }
|
|
160
|
+
: { state, handlers };
|
|
161
|
+
this.connections.set(state.id, tracked);
|
|
71
162
|
}
|
|
72
163
|
|
|
73
164
|
/** Stops tracking a connection. Idempotent. Does NOT release its nick. */
|
|
74
165
|
unregisterConnection(conn: ConnId): void {
|
|
166
|
+
const tracked = this.connections.get(conn);
|
|
167
|
+
if (tracked !== undefined && tracked.admissionRecordId !== undefined) {
|
|
168
|
+
this.releaseAdmission(tracked.admissionRecordId);
|
|
169
|
+
}
|
|
75
170
|
this.connections.delete(conn);
|
|
76
171
|
}
|
|
77
172
|
|
|
@@ -102,6 +197,16 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
102
197
|
return created;
|
|
103
198
|
}
|
|
104
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Peek-only channel lookup that does NOT create the channel. Returns the
|
|
202
|
+
* authoritative {@link ChannelState} when it exists, or `undefined`.
|
|
203
|
+
* Used by query-only commands (CHATHISTORY) that must distinguish a
|
|
204
|
+
* missing channel from an empty one.
|
|
205
|
+
*/
|
|
206
|
+
getChannel(name: ChanName): ChannelState | undefined {
|
|
207
|
+
return this.channels.get(lower(name));
|
|
208
|
+
}
|
|
209
|
+
|
|
105
210
|
// ------------------------------------------------------------------
|
|
106
211
|
// IrcRuntime — transport
|
|
107
212
|
// ------------------------------------------------------------------
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AdmissionConfig,
|
|
3
|
+
type AdmissionDecision,
|
|
2
4
|
type ChannelDelta,
|
|
3
5
|
type ConnectionState,
|
|
4
6
|
FakeClock,
|
|
@@ -500,3 +502,116 @@ describe('InMemoryRuntime — connection lifecycle', () => {
|
|
|
500
502
|
expect(rt.getOrCreateChannel('#foo')).toBe(chan);
|
|
501
503
|
});
|
|
502
504
|
});
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Admission gate
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
/** Strict per-IP cap, generous everything else, for isolated per-IP tests. */
|
|
511
|
+
const IP_CAP_ONLY: AdmissionConfig = {
|
|
512
|
+
maxConnectionsPerIp: 2,
|
|
513
|
+
maxConnectionsPerUser: 100,
|
|
514
|
+
perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** Strict per-user cap, generous everything else, for isolated per-user tests. */
|
|
518
|
+
const USER_CAP_ONLY: AdmissionConfig = {
|
|
519
|
+
maxConnectionsPerIp: 100,
|
|
520
|
+
maxConnectionsPerUser: 2,
|
|
521
|
+
perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
/** Strict rate limit, generous caps, for isolated rate-limit tests. */
|
|
525
|
+
const RATE_ONLY: AdmissionConfig = {
|
|
526
|
+
maxConnectionsPerIp: 100,
|
|
527
|
+
maxConnectionsPerUser: 100,
|
|
528
|
+
perIpConnectionRate: { max: 2, windowMs: 10_000 },
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
describe('InMemoryRuntime — admission gate', () => {
|
|
532
|
+
it('admits the first connection from an IP when a policy is configured', async () => {
|
|
533
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
|
|
534
|
+
const decision = rt.admitConnection('1.2.3.4', undefined);
|
|
535
|
+
expect(decision.ok).toBe(true);
|
|
536
|
+
if (decision.ok) rt.commitAdmission('1.2.3.4', undefined, decision.recordId);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('rejects a connection exceeding the per-IP cap', async () => {
|
|
540
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
|
|
541
|
+
for (let i = 0; i < 2; i++) {
|
|
542
|
+
const d = rt.admitConnection('1.1.1.1', `u${i}`);
|
|
543
|
+
if (d.ok) rt.commitAdmission('1.1.1.1', `u${i}`, d.recordId);
|
|
544
|
+
}
|
|
545
|
+
const third = rt.admitConnection('1.1.1.1', 'u2');
|
|
546
|
+
expect(third.ok).toBe(false);
|
|
547
|
+
if (!third.ok) expect(third.reason).toBe('max_connections_per_ip');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('rejects a connection exceeding the per-user cap', async () => {
|
|
551
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: USER_CAP_ONLY });
|
|
552
|
+
for (let i = 0; i < 2; i++) {
|
|
553
|
+
const d = rt.admitConnection(`10.0.0.${i}`, 'alice');
|
|
554
|
+
if (d.ok) rt.commitAdmission(`10.0.0.${i}`, 'alice', d.recordId);
|
|
555
|
+
}
|
|
556
|
+
const third = rt.admitConnection('10.0.0.9', 'alice');
|
|
557
|
+
expect(third.ok).toBe(false);
|
|
558
|
+
if (!third.ok) expect(third.reason).toBe('max_connections_per_user');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('rejects a connection exceeding the per-IP rate cap', async () => {
|
|
562
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: RATE_ONLY });
|
|
563
|
+
for (let i = 0; i < 2; i++) {
|
|
564
|
+
const d = rt.admitConnection('5.5.5.5', `u${i}`);
|
|
565
|
+
if (d.ok) rt.commitAdmission('5.5.5.5', `u${i}`, d.recordId);
|
|
566
|
+
}
|
|
567
|
+
const third = rt.admitConnection('5.5.5.5', 'u2');
|
|
568
|
+
expect(third.ok).toBe(false);
|
|
569
|
+
if (!third.ok) expect(third.reason).toBe('rate_limited_per_ip');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('releaseAdmission decrements counters so subsequent admissions succeed', () => {
|
|
573
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
|
|
574
|
+
const d1 = rt.admitConnection('1.1.1.1', 'alice');
|
|
575
|
+
expect(d1.ok).toBe(true);
|
|
576
|
+
if (!d1.ok) return;
|
|
577
|
+
rt.commitAdmission('1.1.1.1', 'alice', d1.recordId);
|
|
578
|
+
const d2 = rt.admitConnection('1.1.1.1', 'alice');
|
|
579
|
+
expect(d2.ok).toBe(true);
|
|
580
|
+
if (!d2.ok) return;
|
|
581
|
+
rt.commitAdmission('1.1.1.1', 'alice', d2.recordId);
|
|
582
|
+
expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(false);
|
|
583
|
+
rt.releaseAdmission(d1.recordId);
|
|
584
|
+
expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('unregisterConnection releases the admission record so the IP slot is freed', async () => {
|
|
588
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
|
|
589
|
+
const conn = makeConn('c1', 'alice');
|
|
590
|
+
// Simulate the local-CLI admission flow: admit, commit, register, and
|
|
591
|
+
// pair the record id with the connection for later release.
|
|
592
|
+
const d = rt.admitConnection('9.9.9.9', 'alice');
|
|
593
|
+
expect(d.ok).toBe(true);
|
|
594
|
+
if (!d.ok) return;
|
|
595
|
+
rt.commitAdmission('9.9.9.9', 'alice', d.recordId);
|
|
596
|
+
rt.registerConnection(conn, { send: () => {}, disconnect: () => {} }, d.recordId);
|
|
597
|
+
// IP slot now holds one connection.
|
|
598
|
+
const second = rt.admitConnection('9.9.9.9', 'bob');
|
|
599
|
+
expect(second.ok).toBe(true);
|
|
600
|
+
if (second.ok) rt.commitAdmission('9.9.9.9', 'bob', second.recordId);
|
|
601
|
+
// Third would exceed per-IP cap of 2.
|
|
602
|
+
expect(rt.admitConnection('9.9.9.9', 'carol').ok).toBe(false);
|
|
603
|
+
// Unregistering c1 frees its slot (the runtime releases the paired record).
|
|
604
|
+
rt.unregisterConnection('c1');
|
|
605
|
+
const fourth = rt.admitConnection('9.9.9.9', 'carol');
|
|
606
|
+
expect(fourth.ok).toBe(true);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('returns an admit decision when no admission policy is configured (gate disabled)', () => {
|
|
610
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
611
|
+
const decisions: AdmissionDecision[] = [];
|
|
612
|
+
for (let i = 0; i < 100; i++) {
|
|
613
|
+
decisions.push(rt.admitConnection('1.1.1.1', 'alice'));
|
|
614
|
+
}
|
|
615
|
+
expect(decisions.every((d) => d.ok)).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/irc-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -23,7 +23,17 @@
|
|
|
23
23
|
"test": "vitest run",
|
|
24
24
|
"test:watch": "vitest",
|
|
25
25
|
"coverage": "vitest run --coverage",
|
|
26
|
+
"mutation:protocol": "stryker run stryker.protocol.conf.json",
|
|
27
|
+
"mutation:commands": "stryker run stryker.commands.conf.json",
|
|
28
|
+
"mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
|
|
26
29
|
"clean": "rimraf dist coverage .tsbuildinfo .turbo"
|
|
27
30
|
},
|
|
28
|
-
"files": ["dist", "src", "README.md"]
|
|
31
|
+
"files": ["dist", "src", "README.md"],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"zod": "^3.25.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@stryker-mutator/core": "^9.6.1",
|
|
37
|
+
"@stryker-mutator/vitest-runner": "^9.6.1"
|
|
38
|
+
}
|
|
29
39
|
}
|