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
|
@@ -32,12 +32,9 @@ import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
|
32
32
|
import type { SaslResult } from '../ports.js';
|
|
33
33
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
34
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
|
-
import { hostmaskOf } from '../state/connection.js';
|
|
36
35
|
import type { ConnectionState } from '../state/connection.js';
|
|
37
36
|
import type { Ctx, Reducer } from '../types.js';
|
|
38
|
-
import {
|
|
39
|
-
import { nowAwayLine, replayPersistedAway } from './pre-away.js';
|
|
40
|
-
import { seedReadMarkers } from './read-marker.js';
|
|
37
|
+
import { applyAccountSuccess } from './account-auth.js';
|
|
41
38
|
|
|
42
39
|
/** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
|
|
43
40
|
const SASL_CHUNK_BYTES = 400;
|
|
@@ -172,18 +169,28 @@ function finalizePlain(
|
|
|
172
169
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
173
170
|
}
|
|
174
171
|
|
|
172
|
+
// Prefer the static AccountStore; fall back to the ServicesStore so a
|
|
173
|
+
// nick registered via NickServ can authenticate via SASL PLAIN without a
|
|
174
|
+
// separate static credential table. When neither verifies, fail.
|
|
175
175
|
const store = ctx.accounts;
|
|
176
|
-
|
|
177
|
-
clearSasl(state);
|
|
178
|
-
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const result = store.verify('PLAIN', {
|
|
176
|
+
let result = store?.verify('PLAIN', {
|
|
182
177
|
kind: 'PLAIN',
|
|
183
178
|
username: parsed.username,
|
|
184
179
|
password: parsed.password,
|
|
185
180
|
});
|
|
186
181
|
|
|
182
|
+
if (result === undefined || !result.ok) {
|
|
183
|
+
const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
|
|
184
|
+
if (servicesResult?.ok) {
|
|
185
|
+
result = servicesResult;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result === undefined) {
|
|
190
|
+
clearSasl(state);
|
|
191
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
192
|
+
}
|
|
193
|
+
|
|
187
194
|
return applyResult(state, result, ctx);
|
|
188
195
|
}
|
|
189
196
|
|
|
@@ -220,31 +227,7 @@ function applyResult(
|
|
|
220
227
|
clearSasl(state);
|
|
221
228
|
|
|
222
229
|
if (result.ok) {
|
|
223
|
-
state
|
|
224
|
-
// IRCv3 draft/read-marker: restore the user's per-channel last-read
|
|
225
|
-
// markers from the persisted store so a reconnect resumes at their
|
|
226
|
-
// last read position. No-op when no ReadMarkerStore is bound.
|
|
227
|
-
if (ctx.readMarkers !== undefined) {
|
|
228
|
-
seedReadMarkers(state, ctx.readMarkers, result.account);
|
|
229
|
-
}
|
|
230
|
-
// IRCv3 draft/pre-away: replay the user's persisted away reason onto
|
|
231
|
-
// the fresh connection so a reconnect restores the away state, and
|
|
232
|
-
// emit the 306 numeric so the user sees they are still away. No-op
|
|
233
|
-
// when no AwayStore is bound or no reason is stored.
|
|
234
|
-
const replayedAway =
|
|
235
|
-
ctx.away !== undefined ? replayPersistedAway(state, ctx.away, result.account) : undefined;
|
|
236
|
-
const awayEffects: EffectType[] =
|
|
237
|
-
replayedAway !== undefined
|
|
238
|
-
? [Effect.send(ctx.connId, [nowAwayLine(state, ctx.serverName)])]
|
|
239
|
-
: [];
|
|
240
|
-
return {
|
|
241
|
-
state,
|
|
242
|
-
effects: [
|
|
243
|
-
Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)]),
|
|
244
|
-
...awayEffects,
|
|
245
|
-
...emitAccountNotify({ conn: state, account: result.account }),
|
|
246
|
-
],
|
|
247
|
-
};
|
|
230
|
+
return { state, effects: applyAccountSuccess(state, ctx, result.account) };
|
|
248
231
|
}
|
|
249
232
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
250
233
|
}
|
|
@@ -274,20 +257,6 @@ function clearSasl(state: ConnectionState): void {
|
|
|
274
257
|
delete state.saslBuffer;
|
|
275
258
|
}
|
|
276
259
|
|
|
277
|
-
function loggedIn(ctx: Ctx, account: string): RawLine {
|
|
278
|
-
const nick = nickOf(ctx);
|
|
279
|
-
const hostmask = hostmaskOf(ctx.connection) ?? nick;
|
|
280
|
-
return L(
|
|
281
|
-
`:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function saslSuccess(ctx: Ctx): RawLine {
|
|
286
|
-
return L(
|
|
287
|
-
`:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
260
|
function saslFail(ctx: Ctx): RawLine {
|
|
292
261
|
return L(
|
|
293
262
|
`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
|
|
@@ -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()));
|
|
@@ -202,13 +202,26 @@ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) =>
|
|
|
202
202
|
// this channel. Persist the marker (advances the connection's in-memory
|
|
203
203
|
// marker too); a valueless tag carries no msgid and is dropped here. The
|
|
204
204
|
// TAGMSG still fans out below so the user's other connections learn it.
|
|
205
|
-
|
|
205
|
+
const isReadMarker = isReadMarkerTagmsg(msg.tags);
|
|
206
|
+
if (isReadMarker) {
|
|
206
207
|
const msgid = readMarkerMsgid(msg.tags);
|
|
207
208
|
if (msgid !== undefined) {
|
|
208
209
|
persistReadMarker(ctx, state.nameLower, msgid);
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
// IRCv3 draft/read-marker spec compliance: a read marker is private to
|
|
214
|
+
// the originator's account and MUST NOT be disclosed to other users. The
|
|
215
|
+
// mark TAGMSG fans out ONLY to the originator's own account (account-
|
|
216
|
+
// scoped broadcast); dispatch restricts the recipient set to same-account
|
|
217
|
+
// channel members. An unidentified originator has no account to share the
|
|
218
|
+
// mark with, so the mark stays session-local — no fanout at all (the
|
|
219
|
+
// originator's own client still observes its mark via echo-message below).
|
|
220
|
+
// Every other TAGMSG (typing, generic) keeps the channel-wide fanout.
|
|
221
|
+
const originAccount = ctx.connection.account;
|
|
222
|
+
const suppressFanout = isReadMarker && originAccount === undefined;
|
|
223
|
+
const accountFilter = isReadMarker ? originAccount : undefined;
|
|
224
|
+
|
|
212
225
|
// Broadcast the tag-bearing line to message-tags-enabled members only.
|
|
213
226
|
// When the TAGMSG carries `+draft/typing` or `+draft/read-marker`, the
|
|
214
227
|
// audience widens to also include members that announced the matching
|
|
@@ -217,11 +230,33 @@ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) =>
|
|
|
217
230
|
// `caps` so dispatch applies OR semantics per recipient.
|
|
218
231
|
const tagPrefix = serializeTags(msg.tags);
|
|
219
232
|
const line: RawLine = { text: `${tagPrefix}:${hostmask} TAGMSG ${state.name}` };
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
233
|
+
if (!suppressFanout) {
|
|
234
|
+
const widenCaps = widenAudienceCaps(msg.tags);
|
|
235
|
+
if (widenCaps !== undefined) {
|
|
236
|
+
effects.push(
|
|
237
|
+
Effect.broadcast(
|
|
238
|
+
state.name,
|
|
239
|
+
[line],
|
|
240
|
+
ctx.connId,
|
|
241
|
+
undefined,
|
|
242
|
+
undefined,
|
|
243
|
+
widenCaps,
|
|
244
|
+
accountFilter,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
effects.push(
|
|
249
|
+
Effect.broadcast(
|
|
250
|
+
state.name,
|
|
251
|
+
[line],
|
|
252
|
+
ctx.connId,
|
|
253
|
+
MESSAGE_TAGS_CAP,
|
|
254
|
+
undefined,
|
|
255
|
+
undefined,
|
|
256
|
+
accountFilter,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
225
260
|
}
|
|
226
261
|
|
|
227
262
|
// IRCv3 echo-message: echo the same line back to the sender, after
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { Effect } from '../effects.js';
|
|
22
22
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
23
|
+
import type { PersistedTopic } from '../ports.js';
|
|
23
24
|
import { Numerics } from '../protocol/numerics.js';
|
|
24
25
|
import type { ChannelState, ChannelTopic } from '../state/channel.js';
|
|
25
26
|
import { hostmaskOf } from '../state/connection.js';
|
|
@@ -121,6 +122,10 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
121
122
|
// path that persists the change, so subsequent reads by other
|
|
122
123
|
// connections observe the cleared topic.
|
|
123
124
|
effects.push(Effect.applyChannelDelta(state.name, { topic: null }));
|
|
125
|
+
// ChanServ KEEPTOPIC: clear the persisted snapshot when the channel has
|
|
126
|
+
// KEEPTOPIC enabled, so a subsequent empty-recreate does not restore a
|
|
127
|
+
// stale topic.
|
|
128
|
+
persistKeepTopic(ctx, state.nameLower, undefined);
|
|
124
129
|
} else {
|
|
125
130
|
const topic: ChannelTopic = {
|
|
126
131
|
text: truncated,
|
|
@@ -130,6 +135,9 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
130
135
|
state.topic = topic;
|
|
131
136
|
// Mirror the topic set to the channel authority (see comment above).
|
|
132
137
|
effects.push(Effect.applyChannelDelta(state.name, { topic }));
|
|
138
|
+
// ChanServ KEEPTOPIC: persist the snapshot when the channel has
|
|
139
|
+
// KEEPTOPIC enabled, so a subsequent empty-recreate restores it.
|
|
140
|
+
persistKeepTopic(ctx, state.nameLower, topic);
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
const hostmask = callerHostmask(ctx.connection);
|
|
@@ -145,7 +153,8 @@ function readTopic(
|
|
|
145
153
|
ctx: Ctx,
|
|
146
154
|
effects: EffectType[],
|
|
147
155
|
): { state: ChannelState; effects: EffectType[] } {
|
|
148
|
-
|
|
156
|
+
const topicReply = topicNumerics(state, ctx);
|
|
157
|
+
if (topicReply === undefined) {
|
|
149
158
|
effects.push(
|
|
150
159
|
Effect.send(ctx.connId, [
|
|
151
160
|
numericErr(ctx, Numerics.RPL_NOTOPIC, 'No topic is set', state.name),
|
|
@@ -153,17 +162,58 @@ function readTopic(
|
|
|
153
162
|
);
|
|
154
163
|
return { state, effects };
|
|
155
164
|
}
|
|
165
|
+
effects.push(topicReply);
|
|
166
|
+
return { state, effects };
|
|
167
|
+
}
|
|
156
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;
|
|
157
181
|
const nick = ctx.connection.nick ?? '*';
|
|
158
182
|
const codeStr332 = Numerics.RPL_TOPIC.toString().padStart(3, '0');
|
|
159
183
|
const codeStr333 = Numerics.RPL_TOPICWHOTIME.toString().padStart(3, '0');
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ============================================================================
|
|
193
|
+
// ChanServ KEEPTOPIC persistence
|
|
194
|
+
// ============================================================================
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Persists the topic snapshot for a ChanServ-registered channel with
|
|
198
|
+
* KEEPTOPIC enabled. Called by the topic reducer on every topic change
|
|
199
|
+
* (set or clear) so a subsequent empty-recreate can restore the snapshot.
|
|
200
|
+
*
|
|
201
|
+
* No-op when:
|
|
202
|
+
* - no services store is bound,
|
|
203
|
+
* - the channel is not registered,
|
|
204
|
+
* - KEEPTOPIC is off.
|
|
205
|
+
*
|
|
206
|
+
* `topic === undefined` clears the persisted snapshot; a {@link ChannelTopic}
|
|
207
|
+
* replaces it.
|
|
208
|
+
*/
|
|
209
|
+
function persistKeepTopic(ctx: Ctx, nameLower: string, topic: ChannelTopic | undefined): void {
|
|
210
|
+
const services = ctx.services;
|
|
211
|
+
if (services === undefined) return;
|
|
212
|
+
const rec = services.getChannel(nameLower);
|
|
213
|
+
if (rec === undefined || !rec.keepTopic) return;
|
|
214
|
+
const persisted: PersistedTopic | undefined =
|
|
215
|
+
topic === undefined
|
|
216
|
+
? undefined
|
|
217
|
+
: { text: topic.text, setter: topic.setter, setAt: topic.setAt };
|
|
218
|
+
services.setChannelTopic(nameLower, persisted);
|
|
169
219
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
-
import { BUILD_DATE } from './build-info.js';
|
|
20
|
+
import { BUILD_DATE, PACKAGE_VERSION } from './build-info.js';
|
|
21
21
|
import { DEFAULT_MULTILINE_MAX_BYTES } from './caps/capabilities.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -144,11 +144,14 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
|
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
|
|
147
|
-
* does not supply one.
|
|
148
|
-
*
|
|
149
|
-
* knob
|
|
147
|
+
* does not supply one. Sourced from `irc-core/package.json` at build time via
|
|
148
|
+
* `scripts/generate-build-info.mjs`, so a deployment that omits the
|
|
149
|
+
* `serverVersion` knob advertises the version of the package it was shipped
|
|
150
|
+
* with — no manual sync required. Adapters may still inject a deploy-time
|
|
151
|
+
* version via the `serverVersion` config knob (see the per-adapter config
|
|
152
|
+
* loaders).
|
|
150
153
|
*/
|
|
151
|
-
export const DEFAULT_SERVER_VERSION =
|
|
154
|
+
export const DEFAULT_SERVER_VERSION: string = PACKAGE_VERSION;
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
157
|
* Default "created" text for `003 RPL_CREATED` when a deployment does not
|
|
@@ -162,6 +165,14 @@ export const DEFAULT_SERVER_VERSION = '0.3.0';
|
|
|
162
165
|
*/
|
|
163
166
|
export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
|
|
164
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Default grace period (ms) NickServ applies before force-disconnecting a
|
|
170
|
+
* client using a `ghost`-enforced registered nick without identifying.
|
|
171
|
+
* Matches the Atheme/Anope convention (~30s); overridable per deployment
|
|
172
|
+
* via `ServerConfig.nickEnforceGraceMs`.
|
|
173
|
+
*/
|
|
174
|
+
export const DEFAULT_NICK_ENFORCE_GRACE_MS = 30_000;
|
|
175
|
+
|
|
165
176
|
/**
|
|
166
177
|
* The full server-config schema. Optional fields default to deployment-
|
|
167
178
|
* sensible values so a minimal `{ serverName, networkName }` config is
|
|
@@ -337,6 +348,26 @@ export const ServerConfigSchema = z.object({
|
|
|
337
348
|
* nicks make them meaningless.
|
|
338
349
|
*/
|
|
339
350
|
servicesEnabled: z.boolean().optional(),
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Grace period (ms) NickServ applies before force-disconnecting a client
|
|
354
|
+
* using a `ghost`-enforced registered nick without identifying. See
|
|
355
|
+
* `ServerConfig.nickEnforceGraceMs`. `0` disables the grace (ghost
|
|
356
|
+
* behaves like kill). Omit for the 30s default.
|
|
357
|
+
*/
|
|
358
|
+
nickEnforceGraceMs: z.number().int().nonnegative().optional(),
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* HostServ approval mode for `REQUEST <vhost>`. `true` (the default)
|
|
362
|
+
* preserves the legacy auto-approve behaviour — the requested vhost is
|
|
363
|
+
* recorded against the account immediately (no oper review). `false`
|
|
364
|
+
* switches HostServ into oper-queue mode: `REQUEST` records a pending
|
|
365
|
+
* request that an oper must `APPROVE` (or `REJECT`) via HostServ. The
|
|
366
|
+
* queue + the `APPROVE` / `REJECT` / `LIST` (oper-only) commands are
|
|
367
|
+
* available in either mode, but only `false` routes `REQUEST` through
|
|
368
|
+
* them.
|
|
369
|
+
*/
|
|
370
|
+
hostservAutoApproveVhosts: z.boolean().default(false),
|
|
340
371
|
});
|
|
341
372
|
|
|
342
373
|
/** Input type: what adapters supply (fields with defaults may be omitted). */
|
|
@@ -55,6 +55,17 @@ export interface BroadcastEffect {
|
|
|
55
55
|
* path.
|
|
56
56
|
*/
|
|
57
57
|
caps?: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Account-scoped recipient filter (IRCv3 `draft/read-marker` privacy).
|
|
60
|
+
* When set, dispatch restricts the recipient set to channel members
|
|
61
|
+
* authenticated as this account — a read marker is private to the
|
|
62
|
+
* originator's own account and MUST NOT be disclosed to other users.
|
|
63
|
+
* Comparison is case-insensitive (RFC 1459 fold). Recipients still must
|
|
64
|
+
* satisfy the {@link cap} / {@link caps} filter; the {@link except}
|
|
65
|
+
* originator skip still applies. Used only by the read-marker mark
|
|
66
|
+
* TAGMSG path; every other broadcast leaves this unset (channel-wide).
|
|
67
|
+
*/
|
|
68
|
+
account?: string;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export interface DisconnectEffect {
|
|
@@ -121,6 +132,39 @@ export interface BroadcastWallopsEffect {
|
|
|
121
132
|
except?: ConnId;
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
* NickServ nick-enforcement directive.
|
|
137
|
+
*
|
|
138
|
+
* Emitted by the NICK path when a client takes a registered nick they are
|
|
139
|
+
* not identified as, and the registered account's
|
|
140
|
+
* {@link NickEnforcePolicy} is `ghost` or `kill`. The actor layer (via
|
|
141
|
+
* `dispatch`) interprets the policy:
|
|
142
|
+
*
|
|
143
|
+
* - `none` — never emitted (the reducer sends the warning NOTICE only).
|
|
144
|
+
* - `kill` — `disconnect("Nick enforced by NickServ")` immediately.
|
|
145
|
+
* - `ghost` — schedule the same disconnect after {@link EnforceNickEffect.graceMs};
|
|
146
|
+
* when `graceMs === 0` the disconnect is immediate (matches `kill`).
|
|
147
|
+
*
|
|
148
|
+
* The grace duration is stamped by the reducer from the deployment's
|
|
149
|
+
* `nickEnforceGraceMs` config so `dispatch` stays free of clock access and
|
|
150
|
+
* the in-memory tests stay deterministic (the scheduler is injectable via
|
|
151
|
+
* {@link DispatchOptions.scheduler}).
|
|
152
|
+
*/
|
|
153
|
+
export interface EnforceNickEffect {
|
|
154
|
+
tag: 'EnforceNick';
|
|
155
|
+
/** Connection using the enforced nick. */
|
|
156
|
+
conn: ConnId;
|
|
157
|
+
/** Display spelling of the enforced nick. */
|
|
158
|
+
nick: Nick;
|
|
159
|
+
/** Per-account enforcement policy. */
|
|
160
|
+
policy: import('./ports.js').NickEnforcePolicy;
|
|
161
|
+
/**
|
|
162
|
+
* Grace period in ms before the disconnect fires (`ghost` only).
|
|
163
|
+
* `0` for `kill` and for `ghost` when the deployment disables the grace.
|
|
164
|
+
*/
|
|
165
|
+
graceMs: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
/** Discriminated union of every effect the dispatch interpreter knows. */
|
|
125
169
|
export type Effect =
|
|
126
170
|
| SendEffect
|
|
@@ -131,7 +175,8 @@ export type Effect =
|
|
|
131
175
|
| ReleaseNickEffect
|
|
132
176
|
| ApplyChannelDeltaEffect
|
|
133
177
|
| SendToNickEffect
|
|
134
|
-
| BroadcastWallopsEffect
|
|
178
|
+
| BroadcastWallopsEffect
|
|
179
|
+
| EnforceNickEffect;
|
|
135
180
|
|
|
136
181
|
/** Literal string tag of every {@link Effect} variant. */
|
|
137
182
|
export type EffectTag = Effect['tag'];
|
|
@@ -163,12 +208,14 @@ export const Effect = {
|
|
|
163
208
|
cap?: string,
|
|
164
209
|
capLines?: RawLine[],
|
|
165
210
|
caps?: readonly string[],
|
|
211
|
+
account?: string,
|
|
166
212
|
): Effect {
|
|
167
213
|
const e: BroadcastEffect = { tag: 'Broadcast', chan, lines };
|
|
168
214
|
if (except !== undefined) e.except = except;
|
|
169
215
|
if (cap !== undefined) e.cap = cap;
|
|
170
216
|
if (capLines !== undefined) e.capLines = capLines;
|
|
171
217
|
if (caps !== undefined) e.caps = caps;
|
|
218
|
+
if (account !== undefined) e.account = account;
|
|
172
219
|
return e;
|
|
173
220
|
},
|
|
174
221
|
disconnect(conn: ConnId, reason?: string): Effect {
|
|
@@ -198,4 +245,12 @@ export const Effect = {
|
|
|
198
245
|
if (except !== undefined) e.except = except;
|
|
199
246
|
return e;
|
|
200
247
|
},
|
|
248
|
+
enforceNick(
|
|
249
|
+
conn: ConnId,
|
|
250
|
+
nick: Nick,
|
|
251
|
+
policy: import('./ports.js').NickEnforcePolicy,
|
|
252
|
+
graceMs: number,
|
|
253
|
+
): Effect {
|
|
254
|
+
return { tag: 'EnforceNick', conn, nick, policy, graceMs };
|
|
255
|
+
},
|
|
201
256
|
} as const;
|