serverless-ircd 0.7.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- 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/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- 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 +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -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/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -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 +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -25,6 +25,9 @@ describe('emptyModes', () => {
|
|
|
25
25
|
moderated: false,
|
|
26
26
|
secret: false,
|
|
27
27
|
private: false,
|
|
28
|
+
registered: false,
|
|
29
|
+
blockUnidentified: false,
|
|
30
|
+
moderatedIdent: false,
|
|
28
31
|
});
|
|
29
32
|
// optional fields are absent, not undefined
|
|
30
33
|
expect('key' in modes).toBe(false);
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import {
|
|
29
29
|
type AccountStore,
|
|
30
30
|
type AwayStore,
|
|
31
|
+
CHANSERV_NICK,
|
|
31
32
|
type ChanName,
|
|
32
33
|
type ChannelState,
|
|
33
34
|
type Clock,
|
|
@@ -38,25 +39,32 @@ import {
|
|
|
38
39
|
Effect,
|
|
39
40
|
type Effect as EffectType,
|
|
40
41
|
EmptyMotdProvider,
|
|
42
|
+
HOSTSERV_NICK,
|
|
41
43
|
type IdFactory,
|
|
42
44
|
type IrcMessage,
|
|
43
45
|
LABELED_RESPONSE_CAP_NAME,
|
|
44
46
|
type Logger,
|
|
47
|
+
MEMOSERV_NICK,
|
|
45
48
|
type MessageStore,
|
|
46
49
|
type MonitorStore,
|
|
47
50
|
type MotdProvider,
|
|
48
51
|
type MtlsIdentityProvider,
|
|
49
52
|
type MultilineEntry,
|
|
53
|
+
NICKSERV_NICK,
|
|
50
54
|
type NickHistoryStore,
|
|
51
55
|
NoopLoggerInstance,
|
|
52
56
|
Numerics,
|
|
57
|
+
OPERSERV_NICK,
|
|
53
58
|
type RawLine,
|
|
54
|
-
type
|
|
59
|
+
type Reducer,
|
|
60
|
+
SHORTCUT_TO_SERVICE,
|
|
55
61
|
type ServerConfig,
|
|
56
62
|
type ServerStats,
|
|
63
|
+
type ServicesStore,
|
|
57
64
|
applyLabelToEffects,
|
|
58
65
|
buildCtx,
|
|
59
66
|
buildMultilineFanout,
|
|
67
|
+
caseFold,
|
|
60
68
|
chathistoryReducer,
|
|
61
69
|
extractLabel,
|
|
62
70
|
finalizeMultilineBatch,
|
|
@@ -168,18 +176,6 @@ export interface ConnectionActorOptions {
|
|
|
168
176
|
* preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
|
|
169
177
|
*/
|
|
170
178
|
readonly history?: NickHistoryStore;
|
|
171
|
-
/**
|
|
172
|
-
* Read-marker persistence source for IRCv3 `draft/read-marker` (absent
|
|
173
|
-
* when the deployment does not persist read markers). When bound,
|
|
174
|
-
* `ctx.readMarkers` is defined so a `+draft/read-marker=<msgid>` TAGMSG
|
|
175
|
-
* from an identified, cap-enabled client updates the per-`(account,
|
|
176
|
-
* channel)` marker, and SASL identify seeds the connection's
|
|
177
|
-
* `lastReadMarkers` from the stored values. Omitted (→ `ctx.readMarkers`
|
|
178
|
-
* undefined) preserves the session-only marker behaviour: the connection
|
|
179
|
-
* still tracks its own read position via chathistory playback, but the
|
|
180
|
-
* marker is not shared across connections or restored on reconnect.
|
|
181
|
-
*/
|
|
182
|
-
readonly readMarkers?: ReadMarkerStore;
|
|
183
179
|
/**
|
|
184
180
|
* Away-status persistence source for IRCv3 `draft/pre-away` (absent
|
|
185
181
|
* when the deployment does not persist away reasons). When bound,
|
|
@@ -192,6 +188,22 @@ export interface ConnectionActorOptions {
|
|
|
192
188
|
* is not shared across connections or restored on reconnect.
|
|
193
189
|
*/
|
|
194
190
|
readonly away?: AwayStore;
|
|
191
|
+
/**
|
|
192
|
+
* IRC services persistence source for NickServ / ChanServ / HostServ /
|
|
193
|
+
* MemoServ, plus the per-account read-marker persistence for IRCv3
|
|
194
|
+
* `draft/read-marker` (absent when services are not wired). When bound,
|
|
195
|
+
* the actor routes `PRIVMSG NickServ :…` to the NickServ reducer
|
|
196
|
+
* (REGISTER / IDENTIFY / DROP / INFO) against this store, a
|
|
197
|
+
* `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
|
|
198
|
+
* client updates the per-`(account, channel)` marker via
|
|
199
|
+
* {@link ServicesStore.setLastReadMarker}, SASL identify seeds the
|
|
200
|
+
* connection's `lastReadMarkers` from {@link ServicesStore.listReadMarkers},
|
|
201
|
+
* and SASL PLAIN falls back to it for nicks registered via NickServ.
|
|
202
|
+
* Omitted (→ no services routing) preserves the existing behaviour:
|
|
203
|
+
* `PRIVMSG NickServ :…` is treated as a normal nick-target message and
|
|
204
|
+
* read-marker persistence stays session-only.
|
|
205
|
+
*/
|
|
206
|
+
readonly services?: ServicesStore;
|
|
195
207
|
/**
|
|
196
208
|
* Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
|
|
197
209
|
* when MONITOR push notifications are disabled). When bound, the actor
|
|
@@ -262,8 +274,8 @@ export class ConnectionActor {
|
|
|
262
274
|
private readonly accounts: AccountStore | undefined;
|
|
263
275
|
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
264
276
|
private readonly history: NickHistoryStore | undefined;
|
|
265
|
-
private readonly readMarkers: ReadMarkerStore | undefined;
|
|
266
277
|
private readonly away: AwayStore | undefined;
|
|
278
|
+
private readonly services: ServicesStore | undefined;
|
|
267
279
|
private readonly monitor: MonitorStore | undefined;
|
|
268
280
|
private readonly stats: ServerStats | undefined;
|
|
269
281
|
private readonly reducers: RoutedReducers;
|
|
@@ -316,8 +328,8 @@ export class ConnectionActor {
|
|
|
316
328
|
this.accounts = opts.accounts;
|
|
317
329
|
this.mtlsIdentity = opts.mtlsIdentity;
|
|
318
330
|
this.history = opts.history;
|
|
319
|
-
this.readMarkers = opts.readMarkers;
|
|
320
331
|
this.away = opts.away;
|
|
332
|
+
this.services = opts.services;
|
|
321
333
|
this.monitor = opts.monitor;
|
|
322
334
|
this.stats = opts.stats;
|
|
323
335
|
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
@@ -502,8 +514,8 @@ export class ConnectionActor {
|
|
|
502
514
|
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
503
515
|
...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
|
|
504
516
|
...(this.history !== undefined ? { history: this.history } : {}),
|
|
505
|
-
...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
|
|
506
517
|
...(this.away !== undefined ? { away: this.away } : {}),
|
|
518
|
+
...(this.services !== undefined ? { services: this.services } : {}),
|
|
507
519
|
...(label !== undefined ? { label } : {}),
|
|
508
520
|
});
|
|
509
521
|
|
|
@@ -517,6 +529,28 @@ export class ConnectionActor {
|
|
|
517
529
|
return this.accumulateMultilineLine(msg);
|
|
518
530
|
}
|
|
519
531
|
|
|
532
|
+
// Service shortcut commands (Atheme/Anope convention): rewrite verbs
|
|
533
|
+
// like `NICKSERV IDENTIFY pw` / `NS INFO` / `CS REGISTER #home` to the
|
|
534
|
+
// canonical `PRIVMSG <ServiceNick> :<joined args>` and delegate to the
|
|
535
|
+
// existing service-routing path (`routeMessageTarget` →
|
|
536
|
+
// `serviceReducerFor`). `SHORTCUT_TO_SERVICE` is the single source of
|
|
537
|
+
// truth for the alias → service nick mapping; the service reducers run
|
|
538
|
+
// unchanged against the rewritten `params=[serviceNick, body]` shape.
|
|
539
|
+
const serviceShortcutTarget = SHORTCUT_TO_SERVICE.get(msg.command.toUpperCase());
|
|
540
|
+
if (serviceShortcutTarget !== undefined) {
|
|
541
|
+
const rewritten: IrcMessage = {
|
|
542
|
+
...msg,
|
|
543
|
+
command: 'PRIVMSG',
|
|
544
|
+
params: [serviceShortcutTarget, msg.params.join(' ')],
|
|
545
|
+
};
|
|
546
|
+
return this.routeMessageTarget(
|
|
547
|
+
rewritten,
|
|
548
|
+
this.reducers.privmsgChannel,
|
|
549
|
+
this.reducers.privmsgUser,
|
|
550
|
+
ctx,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
520
554
|
switch (msg.command) {
|
|
521
555
|
// -------- Connection-authority commands --------
|
|
522
556
|
case 'PING':
|
|
@@ -631,6 +665,20 @@ export class ConnectionActor {
|
|
|
631
665
|
)
|
|
632
666
|
: this.reducers.userMode(this.state, msg, ctx).effects;
|
|
633
667
|
|
|
668
|
+
case 'MARKREAD': {
|
|
669
|
+
// draft/read-marker MARKREAD verb. Read-marker persistence is
|
|
670
|
+
// channel-scoped in this deployment, so a non-channel (nick) target
|
|
671
|
+
// is unsupported and silently ignored. An empty target is routed
|
|
672
|
+
// through the reducer so it can FAIL NEED_MORE_PARAMS rather than
|
|
673
|
+
// hitting the generic 421 fallthrough.
|
|
674
|
+
const markreadTarget = msg.params[0] ?? '';
|
|
675
|
+
if (markreadTarget.length === 0 || isChannelTarget(markreadTarget)) {
|
|
676
|
+
const chan = this.channels.getOrCreateChannel(markreadTarget);
|
|
677
|
+
return this.reducers.markreadChannel(chan, msg, ctx).effects;
|
|
678
|
+
}
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
|
|
634
682
|
default:
|
|
635
683
|
// Remaining standard IRC verbs not yet implemented. They fall through
|
|
636
684
|
// to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
|
|
@@ -1324,9 +1372,22 @@ export class ConnectionActor {
|
|
|
1324
1372
|
}
|
|
1325
1373
|
for (const target of targets) {
|
|
1326
1374
|
const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
|
|
1375
|
+
const serviceReducer = this.serviceReducerFor(target, msg.command);
|
|
1327
1376
|
if (isChannelTarget(target)) {
|
|
1328
1377
|
const chan = this.channels.getOrCreateChannel(target);
|
|
1329
1378
|
effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
|
|
1379
|
+
} else if (serviceReducer !== undefined) {
|
|
1380
|
+
// Echo the sender's own PRIVMSG back BEFORE the service reducer's NOTICE
|
|
1381
|
+
// reply — matching how every other PRIVMSG renders (your outgoing line
|
|
1382
|
+
// first, then any reply). Without this ordering a client sees the
|
|
1383
|
+
// service's response above its own message, which reads as a bug.
|
|
1384
|
+
if (ctx.connection.caps.has('echo-message')) {
|
|
1385
|
+
const hostmask = hostmaskOf(this.state) ?? this.state.nick ?? '?';
|
|
1386
|
+
const text = singleParamMsg.params[1] ?? '';
|
|
1387
|
+
const echoLine: RawLine = { text: `:${hostmask} PRIVMSG ${target} :${text}` };
|
|
1388
|
+
effects.push(Effect.send(ctx.connId, [echoLine]));
|
|
1389
|
+
}
|
|
1390
|
+
effects.push(...serviceReducer(this.state, singleParamMsg, ctx).effects);
|
|
1330
1391
|
} else {
|
|
1331
1392
|
effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
|
|
1332
1393
|
}
|
|
@@ -1334,6 +1395,26 @@ export class ConnectionActor {
|
|
|
1334
1395
|
return effects;
|
|
1335
1396
|
}
|
|
1336
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolves the service pseudo-client reducer for a nick target, or
|
|
1400
|
+
* `undefined` when the target is not a service nick / the command is not
|
|
1401
|
+
* `PRIVMSG` / no services store is bound. Centralised so the
|
|
1402
|
+
* `echo-message` echo (and any future service-routing concern) lives in a
|
|
1403
|
+
* single place rather than being duplicated across five near-identical
|
|
1404
|
+
* branches. `NOTICE` / `TAGMSG` to a service nick intentionally return
|
|
1405
|
+
* `undefined` so they fall through to the normal user-target reducer.
|
|
1406
|
+
*
|
|
1407
|
+
* The membership test is a table lookup against {@link SERVICE_REDUCER_KEYS}
|
|
1408
|
+
* (built from the same `*_NICK` constants that back
|
|
1409
|
+
* {@link SHORTCUT_TO_SERVICE}), so the service-nick list has one source of
|
|
1410
|
+
* truth rather than a per-service `if`-chain here.
|
|
1411
|
+
*/
|
|
1412
|
+
private serviceReducerFor(target: string, command: string): Reducer<ConnectionState> | undefined {
|
|
1413
|
+
if (command !== 'PRIVMSG' || this.services === undefined) return undefined;
|
|
1414
|
+
const key = SERVICE_REDUCER_KEYS.get(caseFold('rfc1459', target));
|
|
1415
|
+
return key === undefined ? undefined : this.reducers[key];
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1337
1418
|
private unknownCommandEffect(command: string): EffectType {
|
|
1338
1419
|
const nick = this.state.nick ?? '*';
|
|
1339
1420
|
const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
|
|
@@ -1362,6 +1443,29 @@ function splitCsv(param: string): string[] {
|
|
|
1362
1443
|
return param.split(',').filter((p) => p.length > 0);
|
|
1363
1444
|
}
|
|
1364
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Service reducer keys on {@link RoutedReducers}. Narrower than
|
|
1448
|
+
* `keyof RoutedReducers` so the table lookup resolves to a
|
|
1449
|
+
* `Reducer<ConnectionState>` without a widening cast.
|
|
1450
|
+
*/
|
|
1451
|
+
type ServiceReducerKey = 'nickserv' | 'chanserv' | 'hostserv' | 'memoserv' | 'operserv';
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* rfc1459 case-folded service nick → reducer key. The single source of
|
|
1455
|
+
* truth for "which targets are service nicks" — built from the canonical
|
|
1456
|
+
* `*_NICK` constants that also back {@link SHORTCUT_TO_SERVICE}, so the
|
|
1457
|
+
* dispatcher's shortcut rewrite and `serviceReducerFor`'s routing consult
|
|
1458
|
+
* one set of service nicks rather than each maintaining its own
|
|
1459
|
+
* hand-written `if`-chain.
|
|
1460
|
+
*/
|
|
1461
|
+
const SERVICE_REDUCER_KEYS: ReadonlyMap<string, ServiceReducerKey> = new Map([
|
|
1462
|
+
[caseFold('rfc1459', NICKSERV_NICK), 'nickserv'],
|
|
1463
|
+
[caseFold('rfc1459', CHANSERV_NICK), 'chanserv'],
|
|
1464
|
+
[caseFold('rfc1459', HOSTSERV_NICK), 'hostserv'],
|
|
1465
|
+
[caseFold('rfc1459', MEMOSERV_NICK), 'memoserv'],
|
|
1466
|
+
[caseFold('rfc1459', OPERSERV_NICK), 'operserv'],
|
|
1467
|
+
]);
|
|
1468
|
+
|
|
1365
1469
|
/**
|
|
1366
1470
|
* Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
|
|
1367
1471
|
* future single-server-aware query verb). Returns `undefined` when the
|
|
@@ -1405,6 +1509,7 @@ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
|
1405
1509
|
case 'PRIVMSG':
|
|
1406
1510
|
case 'NOTICE':
|
|
1407
1511
|
case 'TAGMSG':
|
|
1512
|
+
case 'MARKREAD':
|
|
1408
1513
|
return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
|
|
1409
1514
|
case 'INVITE':
|
|
1410
1515
|
// INVITE <nick> <channel> — the channel is the second param.
|
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
MonitorStore,
|
|
25
25
|
RawLine,
|
|
26
26
|
} from '@serverless-ircd/irc-core';
|
|
27
|
-
import { LogLevel, filterClientTags } from '@serverless-ircd/irc-core';
|
|
27
|
+
import { LogLevel, caseFold, filterClientTags } from '@serverless-ircd/irc-core';
|
|
28
28
|
import type { IrcRuntime } from './runtime.js';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -32,12 +32,15 @@ import type { IrcRuntime } from './runtime.js';
|
|
|
32
32
|
* The handler also receives the bound {@link MonitorStore} (or
|
|
33
33
|
* `undefined`) so ReserveNick / ChangeNick / ReleaseNick can react to the
|
|
34
34
|
* runtime's success/failure and enqueue `730`/`731` pushes for subscribed
|
|
35
|
-
* watchers.
|
|
35
|
+
* watchers. The optional 4th argument carries {@link DispatchOptions} for
|
|
36
|
+
* handlers that need it (currently only `EnforceNick`, which reads the
|
|
37
|
+
* injectable grace scheduler); most handlers ignore it.
|
|
36
38
|
*/
|
|
37
39
|
type Handler<E extends Effect> = (
|
|
38
40
|
effect: E,
|
|
39
41
|
runtime: IrcRuntime,
|
|
40
42
|
monitor: MonitorStore | undefined,
|
|
43
|
+
opts: DispatchOptions | undefined,
|
|
41
44
|
) => Promise<void>;
|
|
42
45
|
|
|
43
46
|
const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } = {
|
|
@@ -50,6 +53,50 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
|
|
|
50
53
|
ReleaseNick: (e, r, m) => releaseNickEtc(e, r, m),
|
|
51
54
|
ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
|
|
52
55
|
BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
|
|
56
|
+
EnforceNick: (e, r, _m, opts) => enforceNick(e, r, opts),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Canonical disconnect reason applied by the EnforceNick handler. */
|
|
60
|
+
const ENFORCE_NICK_REASON = 'Nick enforced by NickServ';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Host timer used by the default grace scheduler. Declared locally because
|
|
64
|
+
* the package's `lib` is ES2022 (no DOM/WebWorker/Node globals); on every
|
|
65
|
+
* target (Node, Cloudflare Workers, Lambda) `setTimeout` is a real global,
|
|
66
|
+
* so this purely satisfies the type-checker. Scoped to this module.
|
|
67
|
+
*/
|
|
68
|
+
declare function setTimeout(callback: () => void, ms?: number): unknown;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Interprets an {@link EnforceNickEffect} against the runtime:
|
|
72
|
+
* - `none` — no-op (the reducer already sent the warning NOTICE).
|
|
73
|
+
* - `kill` — immediate `disconnect`.
|
|
74
|
+
* - `ghost` — `disconnect` after `graceMs` via the injectable scheduler
|
|
75
|
+
* ({@link DispatchOptions.scheduler}, defaulting to `setTimeout`); when
|
|
76
|
+
* `graceMs === 0` the disconnect is immediate (matches `kill`).
|
|
77
|
+
*/
|
|
78
|
+
async function enforceNick(
|
|
79
|
+
e: Extract<Effect, { tag: 'EnforceNick' }>,
|
|
80
|
+
r: IrcRuntime,
|
|
81
|
+
opts: DispatchOptions | undefined,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
if (e.policy === 'none') return;
|
|
84
|
+
if (e.policy === 'kill' || e.graceMs <= 0) {
|
|
85
|
+
await r.disconnect(e.conn, ENFORCE_NICK_REASON);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// ghost with a positive grace: schedule the disconnect so the client has
|
|
89
|
+
// a window to IDENTIFY. The scheduler is injectable for deterministic
|
|
90
|
+
// tests; production defaults to setTimeout.
|
|
91
|
+
const schedule = opts?.scheduler ?? defaultScheduler;
|
|
92
|
+
schedule(() => {
|
|
93
|
+
void r.disconnect(e.conn, ENFORCE_NICK_REASON);
|
|
94
|
+
}, e.graceMs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Default scheduler: defers to the host's `setTimeout`. */
|
|
98
|
+
const defaultScheduler: DispatchScheduler = (cb, ms) => {
|
|
99
|
+
setTimeout(cb, ms);
|
|
53
100
|
};
|
|
54
101
|
|
|
55
102
|
async function reserveNickEtc(
|
|
@@ -115,8 +162,29 @@ export interface DispatchOptions {
|
|
|
115
162
|
* dispatch behaviour for callers that have not opted into MONITOR.
|
|
116
163
|
*/
|
|
117
164
|
readonly monitor?: MonitorStore;
|
|
165
|
+
/**
|
|
166
|
+
* Grace-timer scheduler consulted by the `EnforceNick(ghost)` handler.
|
|
167
|
+
* When supplied, the handler defers the `disconnect` by the effect's
|
|
168
|
+
* `graceMs` via this callback instead of firing immediately — giving
|
|
169
|
+
* the client a window to IDENTIFY before NickServ reclaims the nick.
|
|
170
|
+
* Production adapters inject a thin `setTimeout` wrapper (or a Durable
|
|
171
|
+
* Object alarm on Cloudflare); tests inject a capturing fake so the
|
|
172
|
+
* scheduled delay and the flushed disconnect are both deterministic.
|
|
173
|
+
*
|
|
174
|
+
* Omitted → dispatch falls back to the host `setTimeout`. The fallback
|
|
175
|
+
* only matters for `ghost` enforcements with a positive `graceMs`;
|
|
176
|
+
* `kill` and `graceMs === 0` always disconnect immediately.
|
|
177
|
+
*/
|
|
178
|
+
readonly scheduler?: DispatchScheduler;
|
|
118
179
|
}
|
|
119
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Scheduler signature for {@link DispatchOptions.scheduler}. Mirrors the
|
|
183
|
+
* subset of `setTimeout` the `EnforceNick(ghost)` handler needs so tests
|
|
184
|
+
* can substitute a deterministic fake.
|
|
185
|
+
*/
|
|
186
|
+
export type DispatchScheduler = (callback: () => void, ms: number) => void;
|
|
187
|
+
|
|
120
188
|
/**
|
|
121
189
|
* Returns true iff two effects are interchangeable — used to coalesce
|
|
122
190
|
* consecutive duplicates so a reducer's accidental double-send is a no-op.
|
|
@@ -178,7 +246,7 @@ async function handle(
|
|
|
178
246
|
// for exhaustive-union dispatch.
|
|
179
247
|
const handler = handlers[effect.tag] as Handler<Effect>;
|
|
180
248
|
try {
|
|
181
|
-
await handler(effect, runtime, monitor);
|
|
249
|
+
await handler(effect, runtime, monitor, opts);
|
|
182
250
|
} catch (err) {
|
|
183
251
|
if (opts?.logger !== undefined) {
|
|
184
252
|
opts.logger.on(logLevelFor(err), 'dispatch.error', {
|
|
@@ -247,7 +315,7 @@ function logLevelFor(_err: unknown): LogLevel {
|
|
|
247
315
|
* (the connection has gone) is treated as lacking the cap.
|
|
248
316
|
*/
|
|
249
317
|
async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
|
|
250
|
-
if (e.cap === undefined && e.caps === undefined) {
|
|
318
|
+
if (e.cap === undefined && e.caps === undefined && e.account === undefined) {
|
|
251
319
|
await r.broadcast(e.chan, e.lines, e.except);
|
|
252
320
|
return;
|
|
253
321
|
}
|
|
@@ -256,14 +324,33 @@ async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
|
|
|
256
324
|
if (snapshot === null) return;
|
|
257
325
|
|
|
258
326
|
// `caps` (OR semantics) takes precedence; otherwise the single `cap` is
|
|
259
|
-
// the only entry.
|
|
260
|
-
|
|
327
|
+
// the only entry. When neither cap field is set (only `account`), the
|
|
328
|
+
// recipient set is account-scoped with no cap gate — every same-account
|
|
329
|
+
// member receives `lines`. The early-return above guarantees at least one
|
|
330
|
+
// of the three filters is set.
|
|
331
|
+
const capsList: readonly string[] | undefined =
|
|
332
|
+
e.caps ?? (e.cap !== undefined ? [e.cap] : undefined);
|
|
333
|
+
// RFC 1459 case-fold the account filter once so per-recipient comparison
|
|
334
|
+
// is a cheap string equality (draft/read-marker privacy is account-scoped).
|
|
335
|
+
const accountFolded = e.account !== undefined ? caseFold('rfc1459', e.account) : undefined;
|
|
261
336
|
for (const connId of snapshot.members.keys()) {
|
|
262
337
|
if (connId === e.except) continue;
|
|
263
338
|
|
|
264
339
|
const info = await r.getConnectionInfo(connId);
|
|
340
|
+
// Account-scoped recipient gate (draft/read-marker). When set, only
|
|
341
|
+
// members authenticated as this account pass; unidentified peers (no
|
|
342
|
+
// account) and other accounts are excluded — a read marker is private.
|
|
343
|
+
if (accountFolded !== undefined) {
|
|
344
|
+
const recipientAccount = info?.account;
|
|
345
|
+
if (
|
|
346
|
+
recipientAccount === undefined ||
|
|
347
|
+
caseFold('rfc1459', recipientAccount) !== accountFolded
|
|
348
|
+
) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
265
352
|
const recipientCaps = info?.caps ?? EMPTY_CAPS;
|
|
266
|
-
const hasAnyCap = capsList.some((c) => recipientCaps.has(c));
|
|
353
|
+
const hasAnyCap = capsList === undefined || capsList.some((c) => recipientCaps.has(c));
|
|
267
354
|
|
|
268
355
|
let toSend: RawLine[] | undefined;
|
|
269
356
|
if (e.capLines !== undefined) {
|
|
@@ -34,17 +34,23 @@ import {
|
|
|
34
34
|
awayReducer,
|
|
35
35
|
capReducer,
|
|
36
36
|
channelModeReducer,
|
|
37
|
+
chanservReducer,
|
|
37
38
|
defaultCommandCost,
|
|
39
|
+
hostservReducer,
|
|
38
40
|
infoReducer,
|
|
39
41
|
inviteReducer,
|
|
40
42
|
joinReducer,
|
|
41
43
|
kickReducer,
|
|
44
|
+
markreadChannelReducer,
|
|
45
|
+
memoservReducer,
|
|
42
46
|
motdReducer,
|
|
43
47
|
namesReducer,
|
|
44
48
|
nickReducer,
|
|
49
|
+
nickservReducer,
|
|
45
50
|
noticeChannelReducer,
|
|
46
51
|
noticeUserReducer,
|
|
47
52
|
operReducer,
|
|
53
|
+
operservReducer,
|
|
48
54
|
partReducer,
|
|
49
55
|
passReducer,
|
|
50
56
|
pingReducer,
|
|
@@ -100,8 +106,15 @@ export interface RoutedReducers {
|
|
|
100
106
|
readonly noticeUser: Reducer<ConnectionState>;
|
|
101
107
|
readonly tagmsgChannel: Reducer<ChannelState>;
|
|
102
108
|
readonly tagmsgUser: Reducer<ConnectionState>;
|
|
109
|
+
readonly markreadChannel: Reducer<ChannelState>;
|
|
103
110
|
readonly channelMode: Reducer<ChannelState>;
|
|
104
111
|
readonly userMode: Reducer<ConnectionState>;
|
|
112
|
+
// Services reducers (target-routed, not command-routed).
|
|
113
|
+
readonly nickserv: Reducer<ConnectionState>;
|
|
114
|
+
readonly chanserv: Reducer<ConnectionState>;
|
|
115
|
+
readonly hostserv: Reducer<ConnectionState>;
|
|
116
|
+
readonly memoserv: Reducer<ConnectionState>;
|
|
117
|
+
readonly operserv: Reducer<ConnectionState>;
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
/**
|
|
@@ -169,7 +182,13 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
|
|
|
169
182
|
noticeUser: wrap(noticeUserReducer),
|
|
170
183
|
tagmsgChannel: wrap(tagmsgChannelReducer),
|
|
171
184
|
tagmsgUser: wrap(tagmsgUserReducer),
|
|
185
|
+
markreadChannel: wrap(markreadChannelReducer),
|
|
172
186
|
channelMode: wrap(channelModeReducer),
|
|
173
187
|
userMode: wrap(userModeReducer),
|
|
188
|
+
nickserv: wrap(nickservReducer),
|
|
189
|
+
chanserv: wrap(chanservReducer),
|
|
190
|
+
hostserv: wrap(hostservReducer),
|
|
191
|
+
memoserv: wrap(memoservReducer),
|
|
192
|
+
operserv: wrap(operservReducer),
|
|
174
193
|
};
|
|
175
194
|
}
|