serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- 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-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 +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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 +546 -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 +5 -0
- 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 +15 -0
- 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/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 +127 -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/handlers.test.ts +105 -3
- 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 +112 -2
- 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.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 +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- 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 +390 -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 +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- 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 +21 -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 +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -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 +807 -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 +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- 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 +71 -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 +623 -12
- 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 +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- 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,30 @@ 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 ReadMarkerStore,
|
|
55
59
|
type ServerConfig,
|
|
56
60
|
type ServerStats,
|
|
61
|
+
type ServicesStore,
|
|
57
62
|
applyLabelToEffects,
|
|
58
63
|
buildCtx,
|
|
59
64
|
buildMultilineFanout,
|
|
65
|
+
caseFold,
|
|
60
66
|
chathistoryReducer,
|
|
61
67
|
extractLabel,
|
|
62
68
|
finalizeMultilineBatch,
|
|
@@ -168,18 +174,6 @@ export interface ConnectionActorOptions {
|
|
|
168
174
|
* preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
|
|
169
175
|
*/
|
|
170
176
|
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
177
|
/**
|
|
184
178
|
* Away-status persistence source for IRCv3 `draft/pre-away` (absent
|
|
185
179
|
* when the deployment does not persist away reasons). When bound,
|
|
@@ -192,6 +186,22 @@ export interface ConnectionActorOptions {
|
|
|
192
186
|
* is not shared across connections or restored on reconnect.
|
|
193
187
|
*/
|
|
194
188
|
readonly away?: AwayStore;
|
|
189
|
+
/**
|
|
190
|
+
* IRC services persistence source for NickServ / ChanServ / HostServ /
|
|
191
|
+
* MemoServ, plus the per-account read-marker persistence for IRCv3
|
|
192
|
+
* `draft/read-marker` (absent when services are not wired). When bound,
|
|
193
|
+
* the actor routes `PRIVMSG NickServ :…` to the NickServ reducer
|
|
194
|
+
* (REGISTER / IDENTIFY / DROP / INFO) against this store, a
|
|
195
|
+
* `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
|
|
196
|
+
* client updates the per-`(account, channel)` marker via
|
|
197
|
+
* {@link ServicesStore.setLastReadMarker}, SASL identify seeds the
|
|
198
|
+
* connection's `lastReadMarkers` from {@link ServicesStore.listReadMarkers},
|
|
199
|
+
* and SASL PLAIN falls back to it for nicks registered via NickServ.
|
|
200
|
+
* Omitted (→ no services routing) preserves the existing behaviour:
|
|
201
|
+
* `PRIVMSG NickServ :…` is treated as a normal nick-target message and
|
|
202
|
+
* read-marker persistence stays session-only.
|
|
203
|
+
*/
|
|
204
|
+
readonly services?: ServicesStore;
|
|
195
205
|
/**
|
|
196
206
|
* Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
|
|
197
207
|
* when MONITOR push notifications are disabled). When bound, the actor
|
|
@@ -262,8 +272,8 @@ export class ConnectionActor {
|
|
|
262
272
|
private readonly accounts: AccountStore | undefined;
|
|
263
273
|
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
264
274
|
private readonly history: NickHistoryStore | undefined;
|
|
265
|
-
private readonly readMarkers: ReadMarkerStore | undefined;
|
|
266
275
|
private readonly away: AwayStore | undefined;
|
|
276
|
+
private readonly services: ServicesStore | undefined;
|
|
267
277
|
private readonly monitor: MonitorStore | undefined;
|
|
268
278
|
private readonly stats: ServerStats | undefined;
|
|
269
279
|
private readonly reducers: RoutedReducers;
|
|
@@ -316,8 +326,8 @@ export class ConnectionActor {
|
|
|
316
326
|
this.accounts = opts.accounts;
|
|
317
327
|
this.mtlsIdentity = opts.mtlsIdentity;
|
|
318
328
|
this.history = opts.history;
|
|
319
|
-
this.readMarkers = opts.readMarkers;
|
|
320
329
|
this.away = opts.away;
|
|
330
|
+
this.services = opts.services;
|
|
321
331
|
this.monitor = opts.monitor;
|
|
322
332
|
this.stats = opts.stats;
|
|
323
333
|
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
@@ -502,8 +512,8 @@ export class ConnectionActor {
|
|
|
502
512
|
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
503
513
|
...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
|
|
504
514
|
...(this.history !== undefined ? { history: this.history } : {}),
|
|
505
|
-
...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
|
|
506
515
|
...(this.away !== undefined ? { away: this.away } : {}),
|
|
516
|
+
...(this.services !== undefined ? { services: this.services } : {}),
|
|
507
517
|
...(label !== undefined ? { label } : {}),
|
|
508
518
|
});
|
|
509
519
|
|
|
@@ -631,6 +641,20 @@ export class ConnectionActor {
|
|
|
631
641
|
)
|
|
632
642
|
: this.reducers.userMode(this.state, msg, ctx).effects;
|
|
633
643
|
|
|
644
|
+
case 'MARKREAD': {
|
|
645
|
+
// draft/read-marker MARKREAD verb. Read-marker persistence is
|
|
646
|
+
// channel-scoped in this deployment, so a non-channel (nick) target
|
|
647
|
+
// is unsupported and silently ignored. An empty target is routed
|
|
648
|
+
// through the reducer so it can FAIL NEED_MORE_PARAMS rather than
|
|
649
|
+
// hitting the generic 421 fallthrough.
|
|
650
|
+
const markreadTarget = msg.params[0] ?? '';
|
|
651
|
+
if (markreadTarget.length === 0 || isChannelTarget(markreadTarget)) {
|
|
652
|
+
const chan = this.channels.getOrCreateChannel(markreadTarget);
|
|
653
|
+
return this.reducers.markreadChannel(chan, msg, ctx).effects;
|
|
654
|
+
}
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
|
|
634
658
|
default:
|
|
635
659
|
// Remaining standard IRC verbs not yet implemented. They fall through
|
|
636
660
|
// to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
|
|
@@ -1327,6 +1351,36 @@ export class ConnectionActor {
|
|
|
1327
1351
|
if (isChannelTarget(target)) {
|
|
1328
1352
|
const chan = this.channels.getOrCreateChannel(target);
|
|
1329
1353
|
effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
|
|
1354
|
+
} else if (
|
|
1355
|
+
msg.command === 'PRIVMSG' &&
|
|
1356
|
+
this.services !== undefined &&
|
|
1357
|
+
caseFold('rfc1459', target) === caseFold('rfc1459', NICKSERV_NICK)
|
|
1358
|
+
) {
|
|
1359
|
+
effects.push(...this.reducers.nickserv(this.state, singleParamMsg, ctx).effects);
|
|
1360
|
+
} else if (
|
|
1361
|
+
msg.command === 'PRIVMSG' &&
|
|
1362
|
+
this.services !== undefined &&
|
|
1363
|
+
caseFold('rfc1459', target) === caseFold('rfc1459', CHANSERV_NICK)
|
|
1364
|
+
) {
|
|
1365
|
+
effects.push(...this.reducers.chanserv(this.state, singleParamMsg, ctx).effects);
|
|
1366
|
+
} else if (
|
|
1367
|
+
msg.command === 'PRIVMSG' &&
|
|
1368
|
+
this.services !== undefined &&
|
|
1369
|
+
caseFold('rfc1459', target) === caseFold('rfc1459', HOSTSERV_NICK)
|
|
1370
|
+
) {
|
|
1371
|
+
effects.push(...this.reducers.hostserv(this.state, singleParamMsg, ctx).effects);
|
|
1372
|
+
} else if (
|
|
1373
|
+
msg.command === 'PRIVMSG' &&
|
|
1374
|
+
this.services !== undefined &&
|
|
1375
|
+
caseFold('rfc1459', target) === caseFold('rfc1459', MEMOSERV_NICK)
|
|
1376
|
+
) {
|
|
1377
|
+
effects.push(...this.reducers.memoserv(this.state, singleParamMsg, ctx).effects);
|
|
1378
|
+
} else if (
|
|
1379
|
+
msg.command === 'PRIVMSG' &&
|
|
1380
|
+
this.services !== undefined &&
|
|
1381
|
+
caseFold('rfc1459', target) === caseFold('rfc1459', OPERSERV_NICK)
|
|
1382
|
+
) {
|
|
1383
|
+
effects.push(...this.reducers.operserv(this.state, singleParamMsg, ctx).effects);
|
|
1330
1384
|
} else {
|
|
1331
1385
|
effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
|
|
1332
1386
|
}
|
|
@@ -1405,6 +1459,7 @@ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
|
1405
1459
|
case 'PRIVMSG':
|
|
1406
1460
|
case 'NOTICE':
|
|
1407
1461
|
case 'TAGMSG':
|
|
1462
|
+
case 'MARKREAD':
|
|
1408
1463
|
return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
|
|
1409
1464
|
case 'INVITE':
|
|
1410
1465
|
// 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
|
}
|