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
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type Nick,
|
|
28
28
|
type RawLine,
|
|
29
29
|
applyChannelDelta as applyDelta,
|
|
30
|
+
caseFold,
|
|
30
31
|
createChannel,
|
|
31
32
|
decideAdmission,
|
|
32
33
|
toChannelSnapshot,
|
|
@@ -53,21 +54,6 @@ interface TrackedConnection {
|
|
|
53
54
|
admissionRecordId?: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function lower(s: string): string {
|
|
57
|
-
return s.toLowerCase();
|
|
58
|
-
}
|
|
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
57
|
export interface InMemoryRuntimeOptions {
|
|
72
58
|
clock: Clock;
|
|
73
59
|
/**
|
|
@@ -113,7 +99,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
113
99
|
*/
|
|
114
100
|
admitConnection(ip: string, user: string | undefined): AdmissionDecision {
|
|
115
101
|
if (this.admissionConfig === undefined || this.admissionStats === undefined) {
|
|
116
|
-
return { ok: true, recordId:
|
|
102
|
+
return { ok: true, recordId: '' };
|
|
117
103
|
}
|
|
118
104
|
return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
|
|
119
105
|
}
|
|
@@ -185,7 +171,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
185
171
|
* return the same object.
|
|
186
172
|
*/
|
|
187
173
|
getOrCreateChannel(name: ChanName): ChannelState {
|
|
188
|
-
const key =
|
|
174
|
+
const key = caseFold('rfc1459', name);
|
|
189
175
|
const existing = this.channels.get(key);
|
|
190
176
|
if (existing !== undefined) return existing;
|
|
191
177
|
const created = createChannel({
|
|
@@ -204,7 +190,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
204
190
|
* missing channel from an empty one.
|
|
205
191
|
*/
|
|
206
192
|
getChannel(name: ChanName): ChannelState | undefined {
|
|
207
|
-
return this.channels.get(
|
|
193
|
+
return this.channels.get(caseFold('rfc1459', name));
|
|
208
194
|
}
|
|
209
195
|
|
|
210
196
|
// ------------------------------------------------------------------
|
|
@@ -218,7 +204,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
218
204
|
}
|
|
219
205
|
|
|
220
206
|
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
221
|
-
const channel = this.channels.get(
|
|
207
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
222
208
|
if (channel === undefined) return;
|
|
223
209
|
for (const member of channel.members.keys()) {
|
|
224
210
|
if (member === except) continue;
|
|
@@ -240,7 +226,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
240
226
|
lines: RawLine[],
|
|
241
227
|
notFoundLines?: RawLine[],
|
|
242
228
|
): Promise<void> {
|
|
243
|
-
const owner = this.nicks.get(
|
|
229
|
+
const owner = this.nicks.get(caseFold('rfc1459', nick));
|
|
244
230
|
if (owner !== undefined) {
|
|
245
231
|
const conn = this.connections.get(owner);
|
|
246
232
|
if (conn !== undefined) {
|
|
@@ -261,7 +247,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
261
247
|
// ------------------------------------------------------------------
|
|
262
248
|
|
|
263
249
|
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
264
|
-
const key =
|
|
250
|
+
const key = caseFold('rfc1459', nick);
|
|
265
251
|
const existing = this.nicks.get(key);
|
|
266
252
|
if (existing !== undefined && existing !== conn) {
|
|
267
253
|
return { ok: false };
|
|
@@ -271,8 +257,8 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
271
257
|
}
|
|
272
258
|
|
|
273
259
|
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
274
|
-
const newKey =
|
|
275
|
-
const oldKey =
|
|
260
|
+
const newKey = caseFold('rfc1459', newNick);
|
|
261
|
+
const oldKey = caseFold('rfc1459', oldNick);
|
|
276
262
|
const owner = this.nicks.get(newKey);
|
|
277
263
|
if (owner !== undefined && owner !== conn) {
|
|
278
264
|
return false;
|
|
@@ -287,7 +273,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
287
273
|
}
|
|
288
274
|
|
|
289
275
|
async releaseNick(nick: Nick): Promise<void> {
|
|
290
|
-
this.nicks.delete(
|
|
276
|
+
this.nicks.delete(caseFold('rfc1459', nick));
|
|
291
277
|
}
|
|
292
278
|
|
|
293
279
|
// ------------------------------------------------------------------
|
|
@@ -300,7 +286,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
300
286
|
// replace the stored object so subsequent snapshots see the new data
|
|
301
287
|
// while any outstanding references to the prior state stay coherent.
|
|
302
288
|
const next = applyDelta(channel, delta);
|
|
303
|
-
this.channels.set(
|
|
289
|
+
this.channels.set(caseFold('rfc1459', chan), next);
|
|
304
290
|
}
|
|
305
291
|
|
|
306
292
|
// ------------------------------------------------------------------
|
|
@@ -308,7 +294,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
308
294
|
// ------------------------------------------------------------------
|
|
309
295
|
|
|
310
296
|
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
311
|
-
return this.nicks.get(
|
|
297
|
+
return this.nicks.get(caseFold('rfc1459', nick)) ?? null;
|
|
312
298
|
}
|
|
313
299
|
|
|
314
300
|
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
@@ -328,13 +314,13 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
328
314
|
}
|
|
329
315
|
|
|
330
316
|
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
331
|
-
const channel = this.channels.get(
|
|
317
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
332
318
|
if (channel === undefined) return null;
|
|
333
319
|
return toChannelSnapshot(channel);
|
|
334
320
|
}
|
|
335
321
|
|
|
336
322
|
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
337
|
-
const channel = this.channels.get(
|
|
323
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
338
324
|
const out = new Map<ConnId, ConnectionState>();
|
|
339
325
|
if (channel === undefined) return out;
|
|
340
326
|
for (const connId of channel.members.keys()) {
|
|
@@ -227,6 +227,14 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
|
|
|
227
227
|
expect(snap?.members.has('c1')).toBe(true);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
it('treats rfc1459 special chars [ \\ ] ^ as equal to { | } ~', async () => {
|
|
231
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
232
|
+
const a = rt.getOrCreateChannel('#[Foo]');
|
|
233
|
+
const b = rt.getOrCreateChannel('#{foo}');
|
|
234
|
+
expect(b).toBe(a);
|
|
235
|
+
expect(b.nameLower).toBe('#{foo}');
|
|
236
|
+
});
|
|
237
|
+
|
|
230
238
|
it('returns null from getChannelSnapshot for an unknown channel', async () => {
|
|
231
239
|
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
232
240
|
expect(await rt.getChannelSnapshot('#nope')).toBeNull();
|
|
@@ -614,4 +622,15 @@ describe('InMemoryRuntime — admission gate', () => {
|
|
|
614
622
|
}
|
|
615
623
|
expect(decisions.every((d) => d.ok)).toBe(true);
|
|
616
624
|
});
|
|
625
|
+
|
|
626
|
+
it('returns a stable recordId when admission is disabled (no per-call minting)', () => {
|
|
627
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
628
|
+
const d1 = rt.admitConnection('1.1.1.1', 'alice');
|
|
629
|
+
const d2 = rt.admitConnection('2.2.2.2', 'bob');
|
|
630
|
+
expect(d1.ok).toBe(true);
|
|
631
|
+
expect(d2.ok).toBe(true);
|
|
632
|
+
if (d1.ok && d2.ok) {
|
|
633
|
+
expect(d1.recordId).toBe(d2.recordId);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
617
636
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/irc-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@stryker-mutator/core": "^9.6.1",
|
|
37
|
-
"@stryker-mutator/vitest-runner": "^9.6.1"
|
|
37
|
+
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
38
|
+
"vitest": "^4.1.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRC case-mapping (case folding) for nick and channel-name comparison.
|
|
3
|
+
*
|
|
4
|
+
* IRC servers compare nicks and channel names case-insensitively, but the
|
|
5
|
+
* exact set of characters that fold together is advertised to clients via
|
|
6
|
+
* the `005 RPL_ISUPPORT` `CASEMAPPING=` token. This module implements the
|
|
7
|
+
* two relevant mappings:
|
|
8
|
+
*
|
|
9
|
+
* - `ascii` — only `A`–`Z` fold to `a`–`z`.
|
|
10
|
+
* - `rfc1459` — `ascii` plus the four characters `[ \ ] ^` folding to
|
|
11
|
+
* `{ | } ~` respectively (per RFC 1459 §2.2). The targets
|
|
12
|
+
* `{ | } ~` do NOT fold further, so the fold is idempotent:
|
|
13
|
+
* `fold(fold(s)) === fold(s)`.
|
|
14
|
+
*
|
|
15
|
+
* The fold operates on one character at a time and leaves every other byte
|
|
16
|
+
* (including all non-ASCII) unchanged. It is pure and dependency-free so it
|
|
17
|
+
* runs unchanged on every runtime (Node, Workers, Lambda).
|
|
18
|
+
*
|
|
19
|
+
* Advertised via {@link CaseMapping} and consumed by the command reducers
|
|
20
|
+
* and the reference runtime so that the advertised case-mapping and the
|
|
21
|
+
* actual folding never diverge.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Case-mapping algorithms supported for IRC-name comparison. */
|
|
25
|
+
export type CaseMapping = 'rfc1459' | 'ascii';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lowercases `s` according to `mapping`, returning the canonical key used
|
|
29
|
+
* for case-insensitive nick and channel comparison.
|
|
30
|
+
*
|
|
31
|
+
* @param mapping `rfc1459` (default behaviour everywhere in this server) or
|
|
32
|
+
* `ascii` (a strict subset — useful for strict-ASCII tests).
|
|
33
|
+
* @param s The nick or channel name to fold.
|
|
34
|
+
*/
|
|
35
|
+
export function caseFold(mapping: CaseMapping, s: string): string {
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
38
|
+
out += foldChar(mapping, s.charCodeAt(i));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Folds a single UTF-16 code unit per the active mapping. */
|
|
44
|
+
function foldChar(mapping: CaseMapping, code: number): string {
|
|
45
|
+
if (code >= 0x41 && code <= 0x5a) {
|
|
46
|
+
// A–Z → a–z
|
|
47
|
+
return String.fromCharCode(code + 0x20);
|
|
48
|
+
}
|
|
49
|
+
if (mapping === 'rfc1459') {
|
|
50
|
+
switch (code) {
|
|
51
|
+
case 0x5b:
|
|
52
|
+
return '{'; // [ → {
|
|
53
|
+
case 0x5c:
|
|
54
|
+
return '|'; // \ → |
|
|
55
|
+
case 0x5d:
|
|
56
|
+
return '}'; // ] → }
|
|
57
|
+
case 0x5e:
|
|
58
|
+
return '~'; // ^ → ~
|
|
59
|
+
default:
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return String.fromCharCode(code);
|
|
64
|
+
}
|
|
@@ -18,6 +18,7 @@ export { quitReducer } from './quit.js';
|
|
|
18
18
|
export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
|
|
19
19
|
export { partReducer } from './part.js';
|
|
20
20
|
export { motdReducer } from './motd.js';
|
|
21
|
+
export { operReducer, matchOperCred } from './oper.js';
|
|
21
22
|
export {
|
|
22
23
|
isChannelTarget,
|
|
23
24
|
noticeChannelReducer,
|
|
@@ -45,3 +46,4 @@ export {
|
|
|
45
46
|
buildChathistoryBatch,
|
|
46
47
|
CHATHISTORY_CAP,
|
|
47
48
|
} from './chathistory.js';
|
|
49
|
+
export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* case-insensitively against the roster (443 if already a member).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { caseFold } from '../case-fold.js';
|
|
23
24
|
import { Effect } from '../effects.js';
|
|
24
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
26
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -50,15 +51,11 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
|
|
|
50
51
|
return { text: parts.join(' ') };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
return nick.toLowerCase();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Finds a roster entry's connId by nick (case-insensitive). */
|
|
54
|
+
/** Finds a roster entry's connId by nick (case-insensitively, per `CASEMAPPING=rfc1459`). */
|
|
58
55
|
function findConnIdByNick(state: ChannelState, nick: string): string | null {
|
|
59
|
-
const target =
|
|
56
|
+
const target = caseFold('rfc1459', nick);
|
|
60
57
|
for (const entry of state.members.values()) {
|
|
61
|
-
if (
|
|
58
|
+
if (caseFold('rfc1459', entry.nick) === target) return entry.conn;
|
|
62
59
|
}
|
|
63
60
|
return null;
|
|
64
61
|
}
|
|
@@ -132,9 +129,9 @@ export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
132
129
|
return { state, effects };
|
|
133
130
|
}
|
|
134
131
|
|
|
135
|
-
// Success: record the pending invite (by
|
|
132
|
+
// Success: record the pending invite (by case-folded nick) so a later
|
|
136
133
|
// JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
|
|
137
|
-
state.pendingInvites.add(targetNick
|
|
134
|
+
state.pendingInvites.add(caseFold('rfc1459', targetNick));
|
|
138
135
|
|
|
139
136
|
const inviterNick = ctx.connection.nick ?? '*';
|
|
140
137
|
effects.push(
|
|
@@ -39,7 +39,7 @@ const PREFIX = '(ov)@+';
|
|
|
39
39
|
/** Channel name sigils this server recognises. */
|
|
40
40
|
const CHANTYPES = '#';
|
|
41
41
|
|
|
42
|
-
/** Case-mapping algorithm — v1 uses RFC 1459 (matches `
|
|
42
|
+
/** Case-mapping algorithm — v1 uses RFC 1459 (matches `case-fold.ts`). */
|
|
43
43
|
const CASEMAPPING = 'rfc1459';
|
|
44
44
|
|
|
45
45
|
/** Default max mode changes per single MODE command (PLAN §3). */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* channel.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { caseFold } from '../case-fold.js';
|
|
18
19
|
import { Effect } from '../effects.js';
|
|
19
20
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
20
21
|
import type { StoredMessage } from '../ports.js';
|
|
@@ -213,9 +214,9 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
// Invite-only check. pendingInvites stores
|
|
217
|
+
// Invite-only check. pendingInvites stores case-folded nicks.
|
|
217
218
|
const myNick = ctx.connection.nick;
|
|
218
|
-
const invited = myNick !== undefined && state.pendingInvites.has(myNick
|
|
219
|
+
const invited = myNick !== undefined && state.pendingInvites.has(caseFold('rfc1459', myNick));
|
|
219
220
|
if (state.modes.inviteOnly && !invited) {
|
|
220
221
|
effects.push(
|
|
221
222
|
Effect.send(ctx.connId, [
|
|
@@ -258,7 +259,7 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
258
259
|
});
|
|
259
260
|
|
|
260
261
|
// Pending invite is consumed on a successful join.
|
|
261
|
-
if (myNick !== undefined) state.pendingInvites.delete(myNick
|
|
262
|
+
if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
|
|
262
263
|
|
|
263
264
|
// Track the channel on the connection's joined set (cross-authority).
|
|
264
265
|
ctx.connection.joinedChannels.add(state.nameLower);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* one channel per invocation.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { caseFold } from '../case-fold.js';
|
|
16
17
|
import { Effect } from '../effects.js';
|
|
17
18
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
18
19
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -52,14 +53,6 @@ function kickerHostmask(conn: ConnectionState): string {
|
|
|
52
53
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Lowercases a nick using ASCII case folding (good enough for v1; rfc1459
|
|
57
|
-
* case mapping lands with the isupport `CASEMAPPING` token).
|
|
58
|
-
*/
|
|
59
|
-
function lowerNick(nick: string): string {
|
|
60
|
-
return nick.toLowerCase();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
56
|
/**
|
|
64
57
|
* Handles `KICK <chan> <user> [:reason]`.
|
|
65
58
|
*
|
|
@@ -117,11 +110,11 @@ export const kickReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
117
110
|
return { state, effects };
|
|
118
111
|
}
|
|
119
112
|
|
|
120
|
-
// Find the target by nick (case-
|
|
121
|
-
const targetLower =
|
|
113
|
+
// Find the target by nick (case-insensitively, per `CASEMAPPING=rfc1459`).
|
|
114
|
+
const targetLower = caseFold('rfc1459', targetNick);
|
|
122
115
|
let targetConnId: string | undefined;
|
|
123
116
|
for (const entry of state.members.values()) {
|
|
124
|
-
if (
|
|
117
|
+
if (caseFold('rfc1459', entry.nick) === targetLower) {
|
|
125
118
|
targetConnId = entry.conn;
|
|
126
119
|
break;
|
|
127
120
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* the cap).
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { caseFold } from '../case-fold.js';
|
|
29
30
|
import { Effect } from '../effects.js';
|
|
30
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
31
32
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -104,10 +105,10 @@ export const listReducer: Reducer<ChanSnapshot[]> = (state, msg, ctx) => {
|
|
|
104
105
|
for (const rawName of requested) {
|
|
105
106
|
if (lines.length >= max) break;
|
|
106
107
|
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) continue;
|
|
107
|
-
const
|
|
108
|
-
if (seen.has(
|
|
109
|
-
seen.add(
|
|
110
|
-
const chan = state.find((c) => c.nameLower ===
|
|
108
|
+
const key = caseFold('rfc1459', rawName);
|
|
109
|
+
if (seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const chan = state.find((c) => c.nameLower === key);
|
|
111
112
|
if (chan === undefined) continue;
|
|
112
113
|
if (!isVisible(chan, ctx)) continue;
|
|
113
114
|
lines.push(listLine(chan, ctx, nick));
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* for its own user modes, so the reducer's `state` argument is the
|
|
13
13
|
* caller's {@link ConnectionState}. A user may only change their own
|
|
14
14
|
* modes (502 otherwise). The oper flag (`+o`) is granted via the
|
|
15
|
-
*
|
|
15
|
+
* OPER command, not via MODE — `+o` here is rejected with 481.
|
|
16
16
|
*
|
|
17
17
|
* Mode classes (per modern IRC de-facto convention):
|
|
18
18
|
* - Type A (list mode, always takes arg): `b` (ban mask).
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* channel.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { caseFold } from '../case-fold.js';
|
|
31
32
|
import { Effect } from '../effects.js';
|
|
32
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -66,11 +67,6 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
|
|
|
66
67
|
return { text: parts.join(' ') };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/** Lowercases a nick using ASCII case folding (matches kick.ts). */
|
|
70
|
-
function lowerNick(nick: string): string {
|
|
71
|
-
return nick.toLowerCase();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
70
|
/** Boolean channel mode letters → mode-field name. */
|
|
75
71
|
const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
|
|
76
72
|
i: 'inviteOnly',
|
|
@@ -417,9 +413,9 @@ function findMemberByNick(
|
|
|
417
413
|
state: ChannelState,
|
|
418
414
|
nick: string,
|
|
419
415
|
): { conn: string; nick: string; op: boolean; voice: boolean } | null {
|
|
420
|
-
const targetLower =
|
|
416
|
+
const targetLower = caseFold('rfc1459', nick);
|
|
421
417
|
for (const entry of state.members.values()) {
|
|
422
|
-
if (
|
|
418
|
+
if (caseFold('rfc1459', entry.nick) === targetLower) {
|
|
423
419
|
return entry;
|
|
424
420
|
}
|
|
425
421
|
}
|
|
@@ -566,7 +562,7 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
566
562
|
|
|
567
563
|
// Target must be the caller's own nick (case-insensitive).
|
|
568
564
|
const myNick = state.nick;
|
|
569
|
-
if (myNick === undefined ||
|
|
565
|
+
if (myNick === undefined || caseFold('rfc1459', target) !== caseFold('rfc1459', myNick)) {
|
|
570
566
|
effects.push(
|
|
571
567
|
Effect.send(ctx.connId, [
|
|
572
568
|
numericErr(ctx, Numerics.ERR_USERSDONTMATCH, 'Cannot change mode for other users'),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MOTD-lines → IRC numerics builder.
|
|
3
|
+
*
|
|
4
|
+
* Both the registration welcome block ({@link buildWelcomeLines}) and the
|
|
5
|
+
* standalone `MOTD` command ({@link motdReducer}) render the same
|
|
6
|
+
* `375`/`372`×N/`376` sequence (or `422 ERR_NOMOTD` when no MOTD is
|
|
7
|
+
* configured) from the lines supplied by a {@link MotdProvider}. This module
|
|
8
|
+
* owns that rendering so the two call sites cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 2812 §5):
|
|
11
|
+
* - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
|
|
12
|
+
* - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
|
|
13
|
+
* - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
|
|
14
|
+
* - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
|
|
15
|
+
*
|
|
16
|
+
* Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
|
|
17
|
+
* trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
|
|
18
|
+
* 372-line content budget so every emitted line fits within the 512-byte
|
|
19
|
+
* limit (also leaving headroom for message-tags).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { RawLine } from '../effects.js';
|
|
23
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
24
|
+
|
|
25
|
+
/** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
|
|
26
|
+
const MAX_LINE_BYTES = 512;
|
|
27
|
+
|
|
28
|
+
/** Length of the trailing CR-LF that the wire layer appends to each line. */
|
|
29
|
+
const TRAILING_CRLF_LEN = 2;
|
|
30
|
+
|
|
31
|
+
/** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
|
|
32
|
+
const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
|
|
33
|
+
|
|
34
|
+
/** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
|
|
35
|
+
const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
|
|
36
|
+
|
|
37
|
+
/** Fixed text emitted for `422 ERR_NOMOTD`. */
|
|
38
|
+
const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders the MOTD numerics addressed to `nick` from the supplied `lines`.
|
|
42
|
+
*
|
|
43
|
+
* Returns `[422 ERR_NOMOTD]` when `lines` is empty (mirroring the standalone
|
|
44
|
+
* `MOTD` command), otherwise the `375`/`372`×N/`376` block with over-long
|
|
45
|
+
* lines hard-wrapped to fit the 512-byte wire limit.
|
|
46
|
+
*
|
|
47
|
+
* `nick` is taken verbatim from the caller — the welcome path passes the
|
|
48
|
+
* (guaranteed-defined) registering nick, the standalone command passes
|
|
49
|
+
* `connection.nick ?? '*'`.
|
|
50
|
+
*/
|
|
51
|
+
export function buildMotdNumerics(serverName: string, nick: string, lines: string[]): RawLine[] {
|
|
52
|
+
if (lines.length === 0) {
|
|
53
|
+
return [numericLine(serverName, nick, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const out: RawLine[] = [
|
|
57
|
+
numericLine(
|
|
58
|
+
serverName,
|
|
59
|
+
nick,
|
|
60
|
+
Numerics.RPL_MOTDSTART,
|
|
61
|
+
MOTDSTART_TRAILING_TEXT.replace('$server', serverName),
|
|
62
|
+
),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const maxContent = maxMotdContentLen(serverName, nick);
|
|
66
|
+
for (const raw of lines) {
|
|
67
|
+
for (const chunk of splitForMotd(raw, maxContent)) {
|
|
68
|
+
out.push(numericLine(serverName, nick, Numerics.RPL_MOTD, `- ${chunk}`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
out.push(numericLine(serverName, nick, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line.
|
|
78
|
+
*/
|
|
79
|
+
function numericLine(serverName: string, nick: string, code: number, trailing: string): RawLine {
|
|
80
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
81
|
+
return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
|
|
86
|
+
* line for this connection, given the server name and nick.
|
|
87
|
+
*
|
|
88
|
+
* The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
|
|
89
|
+
* per-chunk budget is:
|
|
90
|
+
*
|
|
91
|
+
* 512
|
|
92
|
+
* - 2 (trailing CR-LF)
|
|
93
|
+
* - 1 (leading `:`)
|
|
94
|
+
* - <server>.length
|
|
95
|
+
* - 5 (` 372 `)
|
|
96
|
+
* - <nick>.length
|
|
97
|
+
* - 4 (` :- `)
|
|
98
|
+
*
|
|
99
|
+
* The result is clamped at 0 so a pathologically long server/nick combo
|
|
100
|
+
* cannot drive the budget negative.
|
|
101
|
+
*/
|
|
102
|
+
function maxMotdContentLen(serverName: string, nick: string): number {
|
|
103
|
+
const overhead = TRAILING_CRLF_LEN + 1 + serverName.length + 5 + nick.length + 4;
|
|
104
|
+
return Math.max(0, MAX_LINE_BYTES - overhead);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splits a single MOTD content line into chunks that each fit the per-372
|
|
109
|
+
* content budget.
|
|
110
|
+
*
|
|
111
|
+
* Hard-wraps at the byte budget (no word-boundary awareness) — matching the
|
|
112
|
+
* behavior of mainstream IRC daemons and keeping the rendering deterministic.
|
|
113
|
+
*
|
|
114
|
+
* Edge cases:
|
|
115
|
+
* - Empty string → `['']` (one empty 372 line, still RFC-conformant).
|
|
116
|
+
* - Zero/negative budget (impossible in practice but defensive) → `['']`
|
|
117
|
+
* so the builder always emits at least one 372 per input line.
|
|
118
|
+
*/
|
|
119
|
+
function splitForMotd(text: string, maxLen: number): string[] {
|
|
120
|
+
if (maxLen <= 0) return [''];
|
|
121
|
+
if (text.length === 0) return [''];
|
|
122
|
+
const chunks: string[] = [];
|
|
123
|
+
for (let i = 0; i < text.length; i += maxLen) {
|
|
124
|
+
chunks.push(text.slice(i, i + maxLen));
|
|
125
|
+
}
|
|
126
|
+
return chunks;
|
|
127
|
+
}
|