serverless-ircd 0.8.0 → 0.9.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 +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
|
@@ -413,6 +413,31 @@ function handleList(
|
|
|
413
413
|
// vhost application helpers
|
|
414
414
|
// ============================================================================
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Consults the bound {@link ServicesStore} for an assigned vhost for `account`
|
|
418
|
+
* and, when one is present, applies it to {@link ConnectionState} via
|
|
419
|
+
* {@link applyVhost}. This is the shared login-time hook invoked by every
|
|
420
|
+
* account-success path (SASL PLAIN, PASS-auth via {@link applyAccountSuccess},
|
|
421
|
+
* and NickServ `IDENTIFY` via {@link applyIdentify}) so a reconnecting user
|
|
422
|
+
* with an assigned vhost does not have to re-run `HostServ ON` every session.
|
|
423
|
+
*
|
|
424
|
+
* No-op (no effects emitted, no state change) when no vhost is recorded. The
|
|
425
|
+
* CHGHOST fanout appended to `effects` is empty at SASL / PASS-auth time
|
|
426
|
+
* (registration has not completed and/or no channels are joined yet) but fires
|
|
427
|
+
* for a post-registration NickServ `IDENTIFY` when the user is already in
|
|
428
|
+
* channels — reusing {@link applyVhost} so the wire shape matches `HostServ ON`.
|
|
429
|
+
*/
|
|
430
|
+
export function applyAssignedVhost(
|
|
431
|
+
state: ConnectionState,
|
|
432
|
+
services: ServicesStore,
|
|
433
|
+
account: string,
|
|
434
|
+
effects: EffectType[],
|
|
435
|
+
): void {
|
|
436
|
+
const vhost = services.getVhost(account);
|
|
437
|
+
if (vhost === undefined) return;
|
|
438
|
+
applyVhost(state, vhost, effects);
|
|
439
|
+
}
|
|
440
|
+
|
|
416
441
|
/**
|
|
417
442
|
* Applies `vhost` to {@link ConnectionState.host}, records the prior host
|
|
418
443
|
* on {@link ConnectionState.preVhostHost}, flips
|
|
@@ -421,7 +446,7 @@ function handleList(
|
|
|
421
446
|
* {@link emitChghost} helper so the wire shape matches every other
|
|
422
447
|
* user/host mutation.
|
|
423
448
|
*/
|
|
424
|
-
function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
|
|
449
|
+
export function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
|
|
425
450
|
const oldUser = state.user ?? '';
|
|
426
451
|
const oldHost = state.host ?? '';
|
|
427
452
|
state.preVhostHost = oldHost;
|
|
@@ -457,13 +482,23 @@ function notice(state: ConnectionState, text: string): EffectType {
|
|
|
457
482
|
return Effect.send(state.id, [servicesNotice(HOSTSERV_NICK, state, text)]);
|
|
458
483
|
}
|
|
459
484
|
|
|
485
|
+
/** User-visible HostServ subcommands (shown to every caller). */
|
|
486
|
+
const USER_COMMANDS = 'ON, OFF, REQUEST';
|
|
487
|
+
|
|
488
|
+
/** Oper-only HostServ subcommands, appended to {@link USER_COMMANDS} for opers. */
|
|
489
|
+
const OPER_COMMANDS = 'SET, APPROVE, REJECT, LIST';
|
|
490
|
+
|
|
460
491
|
/** Builds a help `:HostServ!HostServ@services NOTICE <nick> :…` line. */
|
|
461
492
|
function helpNotice(state: ConnectionState): EffectType {
|
|
462
|
-
|
|
493
|
+
const commands =
|
|
494
|
+
state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
|
|
495
|
+
return notice(state, `Available commands: ${commands}`);
|
|
463
496
|
}
|
|
464
497
|
|
|
465
498
|
function unknownNotice(state: ConnectionState): EffectType {
|
|
466
|
-
|
|
499
|
+
const commands =
|
|
500
|
+
state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
|
|
501
|
+
return notice(state, `Unknown command. Available: ${commands}`);
|
|
467
502
|
}
|
|
468
503
|
|
|
469
504
|
/**
|
|
@@ -27,6 +27,7 @@ import type { ConnectionState } from '../state/connection.js';
|
|
|
27
27
|
import type { Ctx, Reducer } from '../types.js';
|
|
28
28
|
import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
|
|
29
29
|
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
30
|
+
import { topicNumerics } from './topic.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
@@ -265,16 +266,27 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
265
266
|
}
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
// All checks passed: add to roster. First joiner gets op per RFC 2812
|
|
269
|
+
// All checks passed: add to roster. First joiner gets op per RFC 2812,
|
|
270
|
+
// unless the channel is ChanServ-registered — in that case the first
|
|
271
|
+
// joiner is opped only when they are the founder or meet the AUTOOP
|
|
272
|
+
// level (computeChanservAutoPrefix), so a random user reconnecting
|
|
273
|
+
// first after a roster-wiping deploy cannot seize op in a channel they
|
|
274
|
+
// do not own. The real founder / access-list users who join later are
|
|
275
|
+
// opped by the same access check on the non-empty path.
|
|
269
276
|
const wasEmpty = state.members.size === 0;
|
|
270
|
-
// ChanServ ACCESS auto-prefix:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
const
|
|
277
|
+
// ChanServ ACCESS auto-prefix: always look up the joiner's access level
|
|
278
|
+
// so the empty-channel gating above and the non-empty case share one
|
|
279
|
+
// code path. `autoPrefix` is non-null only when ChanServ could resolve
|
|
280
|
+
// the joiner's access (services bound, channel registered, joiner
|
|
281
|
+
// identified); it carries {op:false,voice:false} when the joiner is on
|
|
282
|
+
// no list, and null when ChanServ is not authoritative for this join
|
|
283
|
+
// (services unbound / channel unregistered / joiner unidentified).
|
|
284
|
+
const autoPrefix = computeChanservAutoPrefix(ctx, state.nameLower);
|
|
285
|
+
const channelRegistered = ctx.services?.getChannel(state.nameLower) !== undefined;
|
|
286
|
+
// RFC 2812 first-joiner op applies only when the channel is NOT
|
|
287
|
+
// registered. A registered channel's first joiner gets op solely via
|
|
288
|
+
// the access check (founder / AUTOOP), never by the empty-channel rule.
|
|
289
|
+
const grantOp = (wasEmpty && !channelRegistered) || (autoPrefix?.op ?? false);
|
|
278
290
|
const grantVoice = autoPrefix?.voice ?? false;
|
|
279
291
|
const joinerNick = ctx.connection.nick ?? '?';
|
|
280
292
|
state.members.set(ctx.connId, {
|
|
@@ -288,13 +300,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
288
300
|
// before this join and services have a persisted topic snapshot for a
|
|
289
301
|
// keep-topic-enabled registration, restore the topic onto the
|
|
290
302
|
// authoritative ChannelState. The 332/333 numerics are emitted to the
|
|
291
|
-
// joiner below alongside the NAMES list.
|
|
292
|
-
let restoredTopic: ChannelTopic | undefined;
|
|
303
|
+
// joiner below alongside the NAMES list, keyed off `state.topic`.
|
|
293
304
|
if (wasEmpty) {
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
state.topic =
|
|
297
|
-
effects.push(Effect.applyChannelDelta(state.name, { topic:
|
|
305
|
+
const restored = restoreKeepTopic(ctx, state.nameLower);
|
|
306
|
+
if (restored !== undefined) {
|
|
307
|
+
state.topic = restored;
|
|
308
|
+
effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
311
|
|
|
@@ -323,13 +334,15 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
323
334
|
}),
|
|
324
335
|
);
|
|
325
336
|
|
|
326
|
-
// ChanServ auto-prefix broadcast: when ChanServ granted op or voice
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
337
|
+
// ChanServ auto-prefix broadcast: when ChanServ granted op or voice on
|
|
338
|
+
// a non-empty channel, emit a `:ChanServ!ChanServ@services MODE #chan
|
|
339
|
+
// +o nick` broadcast so existing clients update their prefix display.
|
|
340
|
+
// Suppressed on the empty-channel first-join (wasEmpty) because the
|
|
341
|
+
// joiner is the channel's only client and already sees their prefix in
|
|
342
|
+
// the NAMES list — the op grant is silent. Supersedes the implicit
|
|
343
|
+
// voice when op is granted (the channel-prefix grammar already renders
|
|
344
|
+
// `@` for ops).
|
|
345
|
+
if (!wasEmpty && autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
|
|
333
346
|
effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
|
|
334
347
|
}
|
|
335
348
|
|
|
@@ -379,19 +392,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
379
392
|
|
|
380
393
|
effects.push(Effect.send(ctx.connId, lines));
|
|
381
394
|
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
{ text: `:${ctx.serverName} 332 ${nick} ${chanName} :${restoredTopic.text}` },
|
|
389
|
-
{
|
|
390
|
-
text: `:${ctx.serverName} 333 ${nick} ${chanName} ${restoredTopic.setter} ${restoredTopic.setAt}`,
|
|
391
|
-
},
|
|
392
|
-
]),
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
+
// Emit 332/333 topic numerics to the joiner whenever a topic is set on
|
|
396
|
+
// the channel, regardless of whether it came from a ChanServ KEEPTOPIC
|
|
397
|
+
// restore this join, a deploy snapshot, or an earlier joiner. Mirrors
|
|
398
|
+
// the TOPIC read-form reply shape (RFC 2812 §3.2.1).
|
|
399
|
+
const topicReply = topicNumerics(state, ctx);
|
|
400
|
+
if (topicReply !== undefined) effects.push(topicReply);
|
|
395
401
|
|
|
396
402
|
return { state, effects };
|
|
397
403
|
};
|
|
@@ -36,6 +36,7 @@ import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
|
|
|
36
36
|
import type { ConnectionState } from '../state/connection.js';
|
|
37
37
|
import type { Ctx, Reducer } from '../types.js';
|
|
38
38
|
import { emitAccountNotify } from './account-notify.js';
|
|
39
|
+
import { applyAssignedVhost } from './hostserv.js';
|
|
39
40
|
import { deliverUnreadMemos } from './memoserv.js';
|
|
40
41
|
|
|
41
42
|
/** Canonical NickServ pseudo-client nick. */
|
|
@@ -74,6 +75,7 @@ export const nickservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
74
75
|
switch (sub) {
|
|
75
76
|
case 'REGISTER':
|
|
76
77
|
return handleRegister(state, args, ctx, effects);
|
|
78
|
+
case 'ID':
|
|
77
79
|
case 'IDENTIFY':
|
|
78
80
|
return handleIdentify(state, args, ctx, effects);
|
|
79
81
|
case 'DROP':
|
|
@@ -166,9 +168,10 @@ function handleIdentify(
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
/**
|
|
169
|
-
* Applies a successful identify: records the account, sets `+r`,
|
|
170
|
-
*
|
|
171
|
-
*
|
|
171
|
+
* Applies a successful identify: records the account, sets `+r`, re-applies any
|
|
172
|
+
* assigned HostServ vhost (so a reconnecting user does not have to re-run
|
|
173
|
+
* `HostServ ON`), emits the confirmation NOTICE, fans `ACCOUNT` out to peers,
|
|
174
|
+
* and delivers queued memos.
|
|
172
175
|
*/
|
|
173
176
|
function applyIdentify(
|
|
174
177
|
state: ConnectionState,
|
|
@@ -178,6 +181,10 @@ function applyIdentify(
|
|
|
178
181
|
): void {
|
|
179
182
|
state.account = account;
|
|
180
183
|
state.userModes.registered = true;
|
|
184
|
+
// Re-apply an assigned HostServ vhost. At identify time the connection is
|
|
185
|
+
// usually pre-JOIN so the CHGHOST fanout is empty, but a post-JOIN IDENTIFY
|
|
186
|
+
// fans CHGHOST out to the user's current `chghost`-capable peers.
|
|
187
|
+
applyAssignedVhost(state, services, account, effects);
|
|
181
188
|
effects.push(notice(state, `You are now identified for nick ${account}.`));
|
|
182
189
|
effects.push(...emitAccountNotify({ conn: state, account }));
|
|
183
190
|
deliverUnreadMemos(state, services, account, effects);
|
|
@@ -241,7 +248,12 @@ function handleInfo(
|
|
|
241
248
|
|
|
242
249
|
effects.push(notice(state, `Nick: ${rec.nick}`));
|
|
243
250
|
effects.push(notice(state, `Account: ${rec.account}`));
|
|
244
|
-
|
|
251
|
+
// Email is private: only the account owner or an oper may see it. Mirrors
|
|
252
|
+
// the `state.account === rec.account` gate used by SET ENFORCE / the
|
|
253
|
+
// enforcement hook, extended to opers.
|
|
254
|
+
if (state.account === rec.account || state.userModes.oper) {
|
|
255
|
+
effects.push(notice(state, `Email: ${rec.email}`));
|
|
256
|
+
}
|
|
245
257
|
return { state, effects };
|
|
246
258
|
}
|
|
247
259
|
|
|
@@ -22,7 +22,7 @@ import type { ChanName } from '../state/channel.js';
|
|
|
22
22
|
import { hostmaskOf } from '../state/connection.js';
|
|
23
23
|
import type { ConnectionState } from '../state/connection.js';
|
|
24
24
|
import type { Ctx, Reducer } from '../types.js';
|
|
25
|
-
import { passBasedAccountAuth } from './account-auth.js';
|
|
25
|
+
import { attemptPassAccountAuth, passBasedAccountAuth } from './account-auth.js';
|
|
26
26
|
import { generateIsupport } from './isupport.js';
|
|
27
27
|
import { buildMotdNumerics } from './motd-lines.js';
|
|
28
28
|
import { enforceRegisteredNick } from './nickserv.js';
|
|
@@ -161,12 +161,16 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
161
161
|
* Replaces `state.host` with a deterministic cloak derived from the
|
|
162
162
|
* deployment's cloaking secret + the current (real) host. No-op when
|
|
163
163
|
* cloaking is disabled or no host is set yet (e.g. transport didn't supply
|
|
164
|
-
* one, or the connection sets it later via CHGHOST).
|
|
164
|
+
* one, or the connection sets it later via CHGHOST). Also a no-op when a
|
|
165
|
+
* HostServ vhost is already active (`vhostActive`) — the vhost was re-applied
|
|
166
|
+
* by {@link applyAccountSuccess} during SASL / PASS-auth and must win over the
|
|
167
|
+
* cloak so it remains visible in the `001` welcome.
|
|
165
168
|
*/
|
|
166
169
|
function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
|
|
167
170
|
const cloaking = ctx.serverConfig.cloaking;
|
|
168
171
|
if (cloaking === undefined || !cloaking.enabled) return;
|
|
169
172
|
if (state.host === undefined) return;
|
|
173
|
+
if (state.vhostActive === true) return;
|
|
170
174
|
state.host = cloakHost(state.host, cloaking, ctx.networkName);
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -340,33 +344,40 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
340
344
|
/**
|
|
341
345
|
* Reducer for `PASS <password>`.
|
|
342
346
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* {@link
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
347
|
+
* Pre-registration: stashes the attempt as `state.passAttempt` for the
|
|
348
|
+
* server-password gate ({@link passwordGateFailure}) and the at-completion
|
|
349
|
+
* PASS-auth ({@link passBasedAccountAuth}). A second `PASS` before
|
|
350
|
+
* registration completes is rejected with `462 ERR_ALREADYREGISTRED`.
|
|
351
|
+
*
|
|
352
|
+
* Post-registration: a bare server password is meaningless, so only the
|
|
353
|
+
* `<nick>:<password>` form is honoured — as a late account login via
|
|
354
|
+
* {@link attemptPassAccountAuth} against the bound `ctx.accounts`. Every
|
|
355
|
+
* other outcome (bare value, no store, already identified, verify failure)
|
|
356
|
+
* is a silent no-op: no `462`, no disconnect. This lets a client that
|
|
357
|
+
* completed `NICK`+`USER` before sending `PASS` identify after the fact.
|
|
352
358
|
*/
|
|
353
359
|
export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
354
360
|
const effects: EffectType[] = [];
|
|
355
361
|
|
|
356
|
-
|
|
362
|
+
const password = msg.params[0];
|
|
363
|
+
if (password === undefined) {
|
|
357
364
|
effects.push(
|
|
358
365
|
Effect.send(ctx.connId, [
|
|
359
|
-
numericErr(ctx, Numerics.
|
|
366
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
|
|
360
367
|
]),
|
|
361
368
|
);
|
|
362
369
|
return { state, effects };
|
|
363
370
|
}
|
|
364
371
|
|
|
365
|
-
|
|
366
|
-
|
|
372
|
+
if (state.registration === 'registered') {
|
|
373
|
+
effects.push(...attemptPassAccountAuth(state, ctx, password));
|
|
374
|
+
return { state, effects };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (state.passAttempt !== undefined) {
|
|
367
378
|
effects.push(
|
|
368
379
|
Effect.send(ctx.connId, [
|
|
369
|
-
numericErr(ctx, Numerics.
|
|
380
|
+
numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
|
|
370
381
|
]),
|
|
371
382
|
);
|
|
372
383
|
return { state, effects };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service shortcut command aliases (Atheme/Anope convention).
|
|
3
|
+
*
|
|
4
|
+
* Clients and user expectations support shortcut verbs like
|
|
5
|
+
* `NICKSERV REGISTER pw` as an alias for `PRIVMSG NickServ :REGISTER pw`.
|
|
6
|
+
* These are not RFC-standardized but are a de facto convention from
|
|
7
|
+
* services packages (Atheme/Anope) and IRCd alias modules (InspIRCd
|
|
8
|
+
* `m_alias`, UnrealIRCd `alias{}`).
|
|
9
|
+
*
|
|
10
|
+
* The actor's dispatcher rewrites any of these verbs to a
|
|
11
|
+
* `PRIVMSG <ServiceNick> :<joined args>` and delegates to the existing
|
|
12
|
+
* `routeMessageTarget` path, so the service reducers see the canonical
|
|
13
|
+
* `params=[serviceNick, body]` shape unchanged.
|
|
14
|
+
*
|
|
15
|
+
* This module is the **single source of truth** for the alias → service
|
|
16
|
+
* nick mapping. Both the dispatcher rewrite (in `irc-server`'s actor) and
|
|
17
|
+
* the service-nick recognition in `serviceReducerFor` consult the derived
|
|
18
|
+
* {@link SERVICE_NICKS} list rather than maintaining their own copies.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CHANSERV_NICK } from './chanserv.js';
|
|
22
|
+
import { HOSTSERV_NICK } from './hostserv.js';
|
|
23
|
+
import { MEMOSERV_NICK } from './memoserv.js';
|
|
24
|
+
import { NICKSERV_NICK } from './nickserv.js';
|
|
25
|
+
import { OPERSERV_NICK } from './operserv.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shortcut command verb (uppercased) → canonical service nick. Keyed by
|
|
29
|
+
* uppercased command so the dispatcher's `msg.command.toUpperCase()` lookup
|
|
30
|
+
* is case-insensitive. The full set of Atheme/Anope-convention aliases.
|
|
31
|
+
*/
|
|
32
|
+
export const SHORTCUT_TO_SERVICE: ReadonlyMap<string, string> = new Map<string, string>([
|
|
33
|
+
['NICKSERV', NICKSERV_NICK],
|
|
34
|
+
['NS', NICKSERV_NICK],
|
|
35
|
+
['CHANSERV', CHANSERV_NICK],
|
|
36
|
+
['CS', CHANSERV_NICK],
|
|
37
|
+
['HOSTSERV', HOSTSERV_NICK],
|
|
38
|
+
['HS', HOSTSERV_NICK],
|
|
39
|
+
['MEMOSERV', MEMOSERV_NICK],
|
|
40
|
+
['MS', MEMOSERV_NICK],
|
|
41
|
+
['OPERSERV', OPERSERV_NICK],
|
|
42
|
+
['OS', OPERSERV_NICK],
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The canonical, de-duplicated list of service nicks derived from
|
|
47
|
+
* {@link SHORTCUT_TO_SERVICE}'s values. Used by `serviceReducerFor` for the
|
|
48
|
+
* "is this target a service nick?" membership check so the service-nick list
|
|
49
|
+
* lives in exactly one place (this module), not duplicated across an
|
|
50
|
+
* `if`-chain.
|
|
51
|
+
*/
|
|
52
|
+
export const SERVICE_NICKS: readonly string[] = Array.from(new Set(SHORTCUT_TO_SERVICE.values()));
|
|
@@ -153,7 +153,8 @@ function readTopic(
|
|
|
153
153
|
ctx: Ctx,
|
|
154
154
|
effects: EffectType[],
|
|
155
155
|
): { state: ChannelState; effects: EffectType[] } {
|
|
156
|
-
|
|
156
|
+
const topicReply = topicNumerics(state, ctx);
|
|
157
|
+
if (topicReply === undefined) {
|
|
157
158
|
effects.push(
|
|
158
159
|
Effect.send(ctx.connId, [
|
|
159
160
|
numericErr(ctx, Numerics.RPL_NOTOPIC, 'No topic is set', state.name),
|
|
@@ -161,19 +162,31 @@ function readTopic(
|
|
|
161
162
|
);
|
|
162
163
|
return { state, effects };
|
|
163
164
|
}
|
|
165
|
+
effects.push(topicReply);
|
|
166
|
+
return { state, effects };
|
|
167
|
+
}
|
|
164
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Builds the `332 RPL_TOPIC` + `333 RPL_TOPICWHOTIME` {@link Effect.send}
|
|
171
|
+
* for the connection's current nick, or `undefined` when no topic is set
|
|
172
|
+
* on `state.topic`.
|
|
173
|
+
*
|
|
174
|
+
* Shared by the TOPIC read-form ({@link readTopic}) and the JOIN success
|
|
175
|
+
* path so both emit an identical numeric block whenever a topic is
|
|
176
|
+
* present, regardless of whether the topic came from a ChanServ KEEPTOPIC
|
|
177
|
+
* restore, a deploy snapshot, or an earlier joiner.
|
|
178
|
+
*/
|
|
179
|
+
export function topicNumerics(state: ChannelState, ctx: Ctx): EffectType | undefined {
|
|
180
|
+
if (state.topic === undefined) return undefined;
|
|
165
181
|
const nick = ctx.connection.nick ?? '*';
|
|
166
182
|
const codeStr332 = Numerics.RPL_TOPIC.toString().padStart(3, '0');
|
|
167
183
|
const codeStr333 = Numerics.RPL_TOPICWHOTIME.toString().padStart(3, '0');
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
]),
|
|
175
|
-
);
|
|
176
|
-
return { state, effects };
|
|
184
|
+
return Effect.send(ctx.connId, [
|
|
185
|
+
{ text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
|
|
186
|
+
{
|
|
187
|
+
text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
177
190
|
}
|
|
178
191
|
|
|
179
192
|
// ============================================================================
|
|
@@ -92,6 +92,23 @@ export interface MembershipDelta {
|
|
|
92
92
|
conn: ConnId;
|
|
93
93
|
/** Required on `add`; ignored on `remove`. */
|
|
94
94
|
nick?: Nick;
|
|
95
|
+
/**
|
|
96
|
+
* Nick-keyed resolution hint for ChanServ prefix/kick commands. When set,
|
|
97
|
+
* the actor layer resolves nick→connId against the channel roster at apply
|
|
98
|
+
* time and overwrites {@link conn} with the resolved id; if the nick is not
|
|
99
|
+
* on the channel, the delta is a no-op and ChanServ NOTICEs the caller.
|
|
100
|
+
*
|
|
101
|
+
* ChanServ operates on `ConnectionState` and cannot see the roster, so it
|
|
102
|
+
* emits a placeholder {@link conn} (`''`) plus this hint — see
|
|
103
|
+
* `resolveMembershipTarget` in `commands/chanserv.ts`.
|
|
104
|
+
*
|
|
105
|
+
* When `targetNick` is present on an `add` delta, the actor treats the
|
|
106
|
+
* `op`/`voice` fields as a *patch*: only fields set on the delta are
|
|
107
|
+
* applied, others retain the existing entry's value. This lets ChanServ
|
|
108
|
+
* op a voiced user without clobbering their voice (a pure reducer cannot
|
|
109
|
+
* read both flags ahead of time).
|
|
110
|
+
*/
|
|
111
|
+
targetNick?: Nick;
|
|
95
112
|
op?: boolean;
|
|
96
113
|
voice?: boolean;
|
|
97
114
|
}
|