serverless-ircd 0.2.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/CHANGELOG.md +84 -0
- package/README.md +28 -19
- 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 +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- 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 +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -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/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -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 +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -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-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -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 +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -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 +4 -3
- 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/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/registration.test.ts +106 -14
- 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 +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* can pin the timeline and identity space (PLAN §8 determinism rules).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { caseFold } from './case-fold.js';
|
|
10
|
+
|
|
9
11
|
/** Read-only wall clock, epoch milliseconds. */
|
|
10
12
|
export interface Clock {
|
|
11
13
|
now(): number;
|
|
@@ -192,6 +194,68 @@ export interface AccountStore {
|
|
|
192
194
|
verify(mech: string, payload: SaslPayload): SaslResult;
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
/**
|
|
198
|
+
* One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
|
|
199
|
+
* The `username` is also the canonical account name recorded on the
|
|
200
|
+
* connection for `extended-join` / `account-tag`.
|
|
201
|
+
*/
|
|
202
|
+
export interface SaslAccountCredential {
|
|
203
|
+
username: string;
|
|
204
|
+
password: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reference in-memory {@link AccountStore} backed by a static list of PLAIN
|
|
209
|
+
* credentials seeded at construction.
|
|
210
|
+
*
|
|
211
|
+
* Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
|
|
212
|
+
* pattern: the port is synchronous, so adapters that need an async backend
|
|
213
|
+
* (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
|
|
214
|
+
* at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
|
|
215
|
+
* viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
|
|
216
|
+
* is a documented follow-up that swaps in by replacing the construction call.
|
|
217
|
+
*
|
|
218
|
+
* Password comparison is constant-time to avoid the early-return timing
|
|
219
|
+
* oracle a naive `===` would expose. Only `PLAIN` is verified; any other
|
|
220
|
+
* mechanism returns failure (matching the reducer's mech gating).
|
|
221
|
+
*/
|
|
222
|
+
export class InMemoryAccountStore implements AccountStore {
|
|
223
|
+
private readonly creds: ReadonlyMap<string, string>;
|
|
224
|
+
|
|
225
|
+
constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
|
|
226
|
+
this.creds = new Map(credentials.map((c) => [c.username, c.password]));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
230
|
+
if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
|
|
231
|
+
return { ok: false, reason: `unsupported mechanism: ${mech}` };
|
|
232
|
+
}
|
|
233
|
+
const expected = this.creds.get(payload.username);
|
|
234
|
+
if (expected === undefined) {
|
|
235
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
236
|
+
}
|
|
237
|
+
if (!constantTimeEquals(payload.password, expected)) {
|
|
238
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, account: payload.username };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Constant-time string equality. Compares every byte regardless of early
|
|
246
|
+
* mismatches so a remote attacker cannot short-circuit the comparison via a
|
|
247
|
+
* timing side-channel. Returns `false` when the lengths differ (the length
|
|
248
|
+
* is not considered secret for SASL PLAIN passwords).
|
|
249
|
+
*/
|
|
250
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
251
|
+
if (a.length !== b.length) return false;
|
|
252
|
+
let diff = 0;
|
|
253
|
+
for (let i = 0; i < a.length; i++) {
|
|
254
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
255
|
+
}
|
|
256
|
+
return diff === 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
195
259
|
// ============================================================================
|
|
196
260
|
// Chat-history persistence (IRCv3 draft/chathistory)
|
|
197
261
|
// ============================================================================
|
|
@@ -301,7 +365,7 @@ export class InMemoryMessageStore implements MessageStore {
|
|
|
301
365
|
}
|
|
302
366
|
|
|
303
367
|
record(message: StoredMessage): void {
|
|
304
|
-
const key = message.chan
|
|
368
|
+
const key = caseFold('rfc1459', message.chan);
|
|
305
369
|
let arr = this.byChan.get(key);
|
|
306
370
|
if (arr === undefined) {
|
|
307
371
|
arr = [];
|
|
@@ -315,7 +379,7 @@ export class InMemoryMessageStore implements MessageStore {
|
|
|
315
379
|
|
|
316
380
|
query(q: HistoryQuery): StoredMessage[] {
|
|
317
381
|
if (q.limit <= 0) return [];
|
|
318
|
-
const arr = this.byChan.get(q.chan
|
|
382
|
+
const arr = this.byChan.get(caseFold('rfc1459', q.chan));
|
|
319
383
|
if (arr === undefined || arr.length === 0) return [];
|
|
320
384
|
|
|
321
385
|
switch (q.direction) {
|
|
@@ -335,14 +399,14 @@ export class InMemoryMessageStore implements MessageStore {
|
|
|
335
399
|
}
|
|
336
400
|
|
|
337
401
|
hasMsgid(chan: string, msgid: string): boolean {
|
|
338
|
-
const arr = this.byChan.get(chan
|
|
402
|
+
const arr = this.byChan.get(caseFold('rfc1459', chan));
|
|
339
403
|
if (arr === undefined) return false;
|
|
340
404
|
return arr.some((m) => m.msgid === msgid);
|
|
341
405
|
}
|
|
342
406
|
|
|
343
407
|
recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
|
|
344
408
|
if (limit <= 0) return [];
|
|
345
|
-
const arr = this.byChan.get(chan
|
|
409
|
+
const arr = this.byChan.get(caseFold('rfc1459', chan));
|
|
346
410
|
if (arr === undefined || arr.length === 0) return [];
|
|
347
411
|
let start = 0;
|
|
348
412
|
if (sinceMsgid !== undefined) {
|
|
@@ -25,6 +25,17 @@ export interface CloakingConfig {
|
|
|
25
25
|
readonly cloakedSuffix?: string | undefined;
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
39
|
/**
|
|
29
40
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
30
41
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
@@ -53,6 +64,17 @@ export interface ServerConfig {
|
|
|
53
64
|
* (see `serverPassword` note).
|
|
54
65
|
*/
|
|
55
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;
|
|
56
78
|
maxChannelsPerUser: number;
|
|
57
79
|
maxTargetsPerCommand: number;
|
|
58
80
|
maxListEntries: number;
|
|
@@ -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,80 @@
|
|
|
1
|
+
import fc from 'fast-check';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type CaseMapping, caseFold } from '../src/case-fold';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// caseFold — rfc1459 special-character folding
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
describe('caseFold — rfc1459', () => {
|
|
10
|
+
it('folds [Foo] to {foo}', () => {
|
|
11
|
+
expect(caseFold('rfc1459', '[Foo]')).toBe('{foo}');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('folds uppercase A–Z to a–z', () => {
|
|
15
|
+
expect(caseFold('rfc1459', 'ABCXYZ')).toBe('abcxyz');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('folds the four rfc1459 specials [ \\ ] ^ to { | } ~', () => {
|
|
19
|
+
expect(caseFold('rfc1459', '[\\]^')).toBe('{|}~');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not fold { | } ~ any further', () => {
|
|
23
|
+
expect(caseFold('rfc1459', '{|}~')).toBe('{|}~');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('leaves non-folding characters unchanged', () => {
|
|
27
|
+
expect(caseFold('rfc1459', 'a1_-.#@')).toBe('a1_-.#@');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns the empty string unchanged', () => {
|
|
31
|
+
expect(caseFold('rfc1459', '')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// caseFold — ascii mapping (strict subset of rfc1459)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
describe('caseFold — ascii', () => {
|
|
40
|
+
it('folds uppercase A–Z to a–z', () => {
|
|
41
|
+
expect(caseFold('ascii', 'ABCXYZ')).toBe('abcxyz');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does NOT fold the rfc1459 specials [ \\ ] ^', () => {
|
|
45
|
+
expect(caseFold('ascii', '[Foo]')).toBe('[foo]');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves non-folding characters unchanged', () => {
|
|
49
|
+
expect(caseFold('ascii', 'a1_-.#[]')).toBe('a1_-.#[]');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// caseFold — idempotency (property test)
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('caseFold — idempotency', () => {
|
|
58
|
+
const mappings: CaseMapping[] = ['rfc1459', 'ascii'];
|
|
59
|
+
|
|
60
|
+
for (const mapping of mappings) {
|
|
61
|
+
it(`fold(fold(s)) === fold(s) under ${mapping}`, () => {
|
|
62
|
+
const nickish = fc.stringMatching(/[\x21-\x7e]{0,16}/u);
|
|
63
|
+
fc.assert(
|
|
64
|
+
fc.property(nickish, (s) => {
|
|
65
|
+
const once = caseFold(mapping, s);
|
|
66
|
+
expect(caseFold(mapping, once)).toBe(once);
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it('rfc1459 and ascii agree on pure A–Z / a–z input', () => {
|
|
73
|
+
const alpha = fc.stringMatching(/^[A-Za-z]{1,16}$/u);
|
|
74
|
+
fc.assert(
|
|
75
|
+
fc.property(alpha, (s) => {
|
|
76
|
+
expect(caseFold('rfc1459', s)).toBe(caseFold('ascii', s));
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -134,6 +134,21 @@ describe('kickReducer — success', () => {
|
|
|
134
134
|
);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
it('resolves the target using rfc1459 case-mapping ([ and { are equal)', () => {
|
|
138
|
+
const chan = makeChan('#foo');
|
|
139
|
+
addMember(chan, 'c1', 'alice', true);
|
|
140
|
+
addMember(chan, 'c2', 'foo[bar]');
|
|
141
|
+
const conn = makeConn('c1', 'alice');
|
|
142
|
+
const ctx = makeCtx(conn);
|
|
143
|
+
|
|
144
|
+
const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'FOO{BAR}'], tags: {} }, ctx);
|
|
145
|
+
|
|
146
|
+
expect(out.state.members.has('c2')).toBe(false);
|
|
147
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
148
|
+
Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
137
152
|
it('updates the kicker lastSeen to ctx.clock.now()', () => {
|
|
138
153
|
const chan = makeChan('#foo');
|
|
139
154
|
addMember(chan, 'c1', 'alice', true);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { matchOperCred, operReducer } from '../../src/commands/oper';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
|
+
|
|
9
|
+
const baseServerConfig: ServerConfig = {
|
|
10
|
+
serverName: 'irc.example.com',
|
|
11
|
+
networkName: 'ExampleNet',
|
|
12
|
+
maxChannelsPerUser: 30,
|
|
13
|
+
maxTargetsPerCommand: 10,
|
|
14
|
+
maxListEntries: 50,
|
|
15
|
+
nickLen: 30,
|
|
16
|
+
channelLen: 50,
|
|
17
|
+
topicLen: 390,
|
|
18
|
+
quitMessage: 'Client Quit',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const serverConfigWithCreds: ServerConfig = {
|
|
22
|
+
...baseServerConfig,
|
|
23
|
+
operCreds: [
|
|
24
|
+
{ user: 'alice', password: 'secret' },
|
|
25
|
+
{ user: 'bob', password: 'hunter2' },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWithCreds): Ctx {
|
|
30
|
+
return buildCtx({
|
|
31
|
+
serverConfig: config,
|
|
32
|
+
clock: new FakeClock(5_000),
|
|
33
|
+
ids: new SequentialIdFactory(),
|
|
34
|
+
motd: EmptyMotdProvider,
|
|
35
|
+
connection: state,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeState(): ConnectionState {
|
|
40
|
+
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
41
|
+
s.nick = 'alice';
|
|
42
|
+
s.user = 'alice';
|
|
43
|
+
s.host = 'example.com';
|
|
44
|
+
s.realname = 'Alice';
|
|
45
|
+
s.registration = 'registered';
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const L = (text: string): RawLine => ({ text });
|
|
50
|
+
|
|
51
|
+
const oper = (name?: string, password?: string) =>
|
|
52
|
+
({
|
|
53
|
+
command: 'OPER',
|
|
54
|
+
params: name === undefined ? [] : password === undefined ? [name] : [name, password],
|
|
55
|
+
tags: {},
|
|
56
|
+
}) as const;
|
|
57
|
+
|
|
58
|
+
describe('operReducer — successful authentication', () => {
|
|
59
|
+
it('sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials', () => {
|
|
60
|
+
const state = makeState();
|
|
61
|
+
const ctx = makeCtx(state);
|
|
62
|
+
|
|
63
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
64
|
+
|
|
65
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
66
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
67
|
+
Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('matches the second configured credential pair', () => {
|
|
72
|
+
const state = makeState();
|
|
73
|
+
const ctx = makeCtx(state);
|
|
74
|
+
|
|
75
|
+
const out = operReducer(state, oper('bob', 'hunter2'), ctx);
|
|
76
|
+
|
|
77
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns the same state reference (mutation permitted, no copy)', () => {
|
|
81
|
+
const state = makeState();
|
|
82
|
+
const ctx = makeCtx(state);
|
|
83
|
+
|
|
84
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
85
|
+
|
|
86
|
+
expect(out.state).toBe(state);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('updates connection lastSeen to ctx.clock.now()', () => {
|
|
90
|
+
const state = makeState();
|
|
91
|
+
expect(state.lastSeen).toBe(0);
|
|
92
|
+
const ctx = makeCtx(state);
|
|
93
|
+
|
|
94
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
95
|
+
|
|
96
|
+
expect(out.state.lastSeen).toBe(5_000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts a lower-case command token', () => {
|
|
100
|
+
const state = makeState();
|
|
101
|
+
const ctx = makeCtx(state);
|
|
102
|
+
|
|
103
|
+
const out = operReducer(state, { command: 'oper', params: ['alice', 'secret'], tags: {} }, ctx);
|
|
104
|
+
|
|
105
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('operReducer — already an operator (graceful no-op)', () => {
|
|
110
|
+
it('re-emits 381 without error when the connection is already oper', () => {
|
|
111
|
+
const state = makeState();
|
|
112
|
+
state.userModes.oper = true;
|
|
113
|
+
const ctx = makeCtx(state);
|
|
114
|
+
|
|
115
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
116
|
+
|
|
117
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
118
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
119
|
+
Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not require correct credentials when already oper', () => {
|
|
124
|
+
const state = makeState();
|
|
125
|
+
state.userModes.oper = true;
|
|
126
|
+
const ctx = makeCtx(state);
|
|
127
|
+
|
|
128
|
+
const out = operReducer(state, oper('alice', 'wrong'), ctx);
|
|
129
|
+
|
|
130
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
131
|
+
expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
|
|
132
|
+
// Critical: never a 464 for an already-oper connection.
|
|
133
|
+
for (const e of out.effects) {
|
|
134
|
+
if (e.tag === 'Send') for (const line of e.lines) expect(line.text).not.toContain(' 464 ');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('operReducer — wrong credentials', () => {
|
|
140
|
+
it('emits 464 ERR_PASSWDMISMATCH for a wrong password', () => {
|
|
141
|
+
const state = makeState();
|
|
142
|
+
const ctx = makeCtx(state);
|
|
143
|
+
|
|
144
|
+
const out = operReducer(state, oper('alice', 'wrong'), ctx);
|
|
145
|
+
|
|
146
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
147
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
148
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('emits 464 ERR_PASSWDMISMATCH for an unknown operator name', () => {
|
|
153
|
+
const state = makeState();
|
|
154
|
+
const ctx = makeCtx(state);
|
|
155
|
+
|
|
156
|
+
const out = operReducer(state, oper('mallory', 'whatever'), ctx);
|
|
157
|
+
|
|
158
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
159
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
160
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('operReducer — no credentials configured', () => {
|
|
166
|
+
it('emits 491 ERR_NOOPERHOST when operCreds is absent', () => {
|
|
167
|
+
const state = makeState();
|
|
168
|
+
const ctx = makeCtx(state, baseServerConfig);
|
|
169
|
+
|
|
170
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
171
|
+
|
|
172
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
173
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
174
|
+
Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('emits 491 ERR_NOOPERHOST when operCreds is an empty array', () => {
|
|
179
|
+
const state = makeState();
|
|
180
|
+
const ctx = makeCtx(state, { ...baseServerConfig, operCreds: [] });
|
|
181
|
+
|
|
182
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
183
|
+
|
|
184
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
185
|
+
expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
|
|
186
|
+
for (const e of out.effects) {
|
|
187
|
+
if (e.tag === 'Send') for (const line of e.lines) expect(line.text).toContain(' 491 ');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('operReducer — missing parameters', () => {
|
|
193
|
+
it('emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied', () => {
|
|
194
|
+
const state = makeState();
|
|
195
|
+
const ctx = makeCtx(state);
|
|
196
|
+
|
|
197
|
+
const out = operReducer(state, oper(), ctx);
|
|
198
|
+
|
|
199
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
200
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
201
|
+
Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('emits 461 ERR_NEEDMOREPARAMS when only the name is supplied', () => {
|
|
206
|
+
const state = makeState();
|
|
207
|
+
const ctx = makeCtx(state);
|
|
208
|
+
|
|
209
|
+
const out = operReducer(state, oper('alice'), ctx);
|
|
210
|
+
|
|
211
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
212
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
213
|
+
Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
|
|
218
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
219
|
+
const ctx = makeCtx(state);
|
|
220
|
+
|
|
221
|
+
const out = operReducer(state, oper(), ctx);
|
|
222
|
+
|
|
223
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
224
|
+
Effect.send('c1', [L(':irc.example.com 461 * OPER :Not enough parameters')]),
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('matchOperCred', () => {
|
|
230
|
+
it('returns true when a credential pair matches exactly', () => {
|
|
231
|
+
const creds = [
|
|
232
|
+
{ user: 'alice', password: 'secret' },
|
|
233
|
+
{ user: 'bob', password: 'hunter2' },
|
|
234
|
+
];
|
|
235
|
+
expect(matchOperCred(creds, 'bob', 'hunter2')).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('returns false when the password does not match', () => {
|
|
239
|
+
const creds = [{ user: 'alice', password: 'secret' }];
|
|
240
|
+
expect(matchOperCred(creds, 'alice', 'nope')).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('returns false when no user matches', () => {
|
|
244
|
+
const creds = [{ user: 'alice', password: 'secret' }];
|
|
245
|
+
expect(matchOperCred(creds, 'mallory', 'secret')).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns false for an empty credential list', () => {
|
|
249
|
+
expect(matchOperCred([], 'alice', 'secret')).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does a case-sensitive comparison of both fields', () => {
|
|
253
|
+
const creds = [{ user: 'alice', password: 'secret' }];
|
|
254
|
+
expect(matchOperCred(creds, 'Alice', 'secret')).toBe(false);
|
|
255
|
+
expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
});
|