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
|
@@ -18,14 +18,16 @@
|
|
|
18
18
|
import { caseFold } from '../case-fold.js';
|
|
19
19
|
import { Effect } from '../effects.js';
|
|
20
20
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
21
|
-
import type { StoredMessage } from '../ports.js';
|
|
21
|
+
import type { PersistedTopic, StoredMessage } from '../ports.js';
|
|
22
22
|
import { wrapBatch } from '../protocol/batch.js';
|
|
23
23
|
import { Numerics } from '../protocol/numerics.js';
|
|
24
|
-
import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
|
|
24
|
+
import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
|
|
25
25
|
import { hostmaskOf } from '../state/connection.js';
|
|
26
26
|
import type { ConnectionState } from '../state/connection.js';
|
|
27
27
|
import type { Ctx, Reducer } from '../types.js';
|
|
28
|
+
import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
|
|
28
29
|
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
30
|
+
import { topicNumerics } from './topic.js';
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
@@ -226,6 +228,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
226
228
|
return { state, effects };
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
// ChanServ +R check: unidentified clients may not join a +R channel.
|
|
232
|
+
if (state.modes.blockUnidentified && ctx.connection.account === undefined) {
|
|
233
|
+
effects.push(
|
|
234
|
+
Effect.send(ctx.connId, [
|
|
235
|
+
numericErr(
|
|
236
|
+
ctx,
|
|
237
|
+
Numerics.ERR_ALLMUSTREGISTER,
|
|
238
|
+
'You must register with NickServ to join this channel',
|
|
239
|
+
rawName,
|
|
240
|
+
),
|
|
241
|
+
]),
|
|
242
|
+
);
|
|
243
|
+
return { state, effects };
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
// Limit check.
|
|
230
247
|
if (state.modes.limit !== undefined && state.members.size >= state.modes.limit) {
|
|
231
248
|
effects.push(
|
|
@@ -249,15 +266,49 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
249
266
|
}
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
// All checks passed: add to roster. First joiner gets op per RFC 2812
|
|
253
|
-
|
|
269
|
+
// All checks passed: add to roster. First joiner gets op per RFC 2812,
|
|
270
|
+
// unless the channel is ChanServ-registered — in that case the first
|
|
271
|
+
// joiner is opped only when they are the founder or meet the AUTOOP
|
|
272
|
+
// level (computeChanservAutoPrefix), so a random user reconnecting
|
|
273
|
+
// first after a roster-wiping deploy cannot seize op in a channel they
|
|
274
|
+
// do not own. The real founder / access-list users who join later are
|
|
275
|
+
// opped by the same access check on the non-empty path.
|
|
276
|
+
const wasEmpty = state.members.size === 0;
|
|
277
|
+
// ChanServ ACCESS auto-prefix: always look up the joiner's access level
|
|
278
|
+
// so the empty-channel gating above and the non-empty case share one
|
|
279
|
+
// code path. `autoPrefix` is non-null only when ChanServ could resolve
|
|
280
|
+
// the joiner's access (services bound, channel registered, joiner
|
|
281
|
+
// identified); it carries {op:false,voice:false} when the joiner is on
|
|
282
|
+
// no list, and null when ChanServ is not authoritative for this join
|
|
283
|
+
// (services unbound / channel unregistered / joiner unidentified).
|
|
284
|
+
const autoPrefix = computeChanservAutoPrefix(ctx, state.nameLower);
|
|
285
|
+
const channelRegistered = ctx.services?.getChannel(state.nameLower) !== undefined;
|
|
286
|
+
// RFC 2812 first-joiner op applies only when the channel is NOT
|
|
287
|
+
// registered. A registered channel's first joiner gets op solely via
|
|
288
|
+
// the access check (founder / AUTOOP), never by the empty-channel rule.
|
|
289
|
+
const grantOp = (wasEmpty && !channelRegistered) || (autoPrefix?.op ?? false);
|
|
290
|
+
const grantVoice = autoPrefix?.voice ?? false;
|
|
291
|
+
const joinerNick = ctx.connection.nick ?? '?';
|
|
254
292
|
state.members.set(ctx.connId, {
|
|
255
293
|
conn: ctx.connId,
|
|
256
|
-
nick:
|
|
257
|
-
op:
|
|
258
|
-
voice:
|
|
294
|
+
nick: joinerNick,
|
|
295
|
+
op: grantOp,
|
|
296
|
+
voice: grantVoice,
|
|
259
297
|
});
|
|
260
298
|
|
|
299
|
+
// ChanServ KEEPTOPIC restore: when the channel was empty immediately
|
|
300
|
+
// before this join and services have a persisted topic snapshot for a
|
|
301
|
+
// keep-topic-enabled registration, restore the topic onto the
|
|
302
|
+
// authoritative ChannelState. The 332/333 numerics are emitted to the
|
|
303
|
+
// joiner below alongside the NAMES list, keyed off `state.topic`.
|
|
304
|
+
if (wasEmpty) {
|
|
305
|
+
const restored = restoreKeepTopic(ctx, state.nameLower);
|
|
306
|
+
if (restored !== undefined) {
|
|
307
|
+
state.topic = restored;
|
|
308
|
+
effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
261
312
|
// Pending invite is consumed on a successful join.
|
|
262
313
|
if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
|
|
263
314
|
|
|
@@ -275,13 +326,26 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
275
326
|
{
|
|
276
327
|
type: 'add',
|
|
277
328
|
conn: ctx.connId,
|
|
278
|
-
nick:
|
|
279
|
-
op:
|
|
329
|
+
nick: joinerNick,
|
|
330
|
+
op: grantOp,
|
|
331
|
+
voice: grantVoice,
|
|
280
332
|
},
|
|
281
333
|
],
|
|
282
334
|
}),
|
|
283
335
|
);
|
|
284
336
|
|
|
337
|
+
// ChanServ auto-prefix broadcast: when ChanServ granted op or voice on
|
|
338
|
+
// a non-empty channel, emit a `:ChanServ!ChanServ@services MODE #chan
|
|
339
|
+
// +o nick` broadcast so existing clients update their prefix display.
|
|
340
|
+
// Suppressed on the empty-channel first-join (wasEmpty) because the
|
|
341
|
+
// joiner is the channel's only client and already sees their prefix in
|
|
342
|
+
// the NAMES list — the op grant is silent. Supersedes the implicit
|
|
343
|
+
// voice when op is granted (the channel-prefix grammar already renders
|
|
344
|
+
// `@` for ops).
|
|
345
|
+
if (!wasEmpty && autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
|
|
346
|
+
effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
|
|
347
|
+
}
|
|
348
|
+
|
|
285
349
|
// IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
|
|
286
350
|
// bound MessageStore receives the recent backlog as a BATCH *before*
|
|
287
351
|
// the JOIN broadcast, then the per-(connection, channel) "last read"
|
|
@@ -328,6 +392,13 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
328
392
|
|
|
329
393
|
effects.push(Effect.send(ctx.connId, lines));
|
|
330
394
|
|
|
395
|
+
// Emit 332/333 topic numerics to the joiner whenever a topic is set on
|
|
396
|
+
// the channel, regardless of whether it came from a ChanServ KEEPTOPIC
|
|
397
|
+
// restore this join, a deploy snapshot, or an earlier joiner. Mirrors
|
|
398
|
+
// the TOPIC read-form reply shape (RFC 2812 §3.2.1).
|
|
399
|
+
const topicReply = topicNumerics(state, ctx);
|
|
400
|
+
if (topicReply !== undefined) effects.push(topicReply);
|
|
401
|
+
|
|
331
402
|
return { state, effects };
|
|
332
403
|
};
|
|
333
404
|
|
|
@@ -360,3 +431,94 @@ export function handleJoinZero(ctx: Ctx): EffectType[] {
|
|
|
360
431
|
ctx.connection.joinedChannels.clear();
|
|
361
432
|
return effects;
|
|
362
433
|
}
|
|
434
|
+
|
|
435
|
+
// ============================================================================
|
|
436
|
+
// ChanServ KEEPTOPIC restore
|
|
437
|
+
// ============================================================================
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Returns the persisted topic snapshot to restore on a JOIN that re-creates
|
|
441
|
+
* an empty channel, or `undefined` when:
|
|
442
|
+
* - no services store is bound,
|
|
443
|
+
* - the channel is not registered,
|
|
444
|
+
* - KEEPTOPIC is off, or
|
|
445
|
+
* - no topic snapshot is stored.
|
|
446
|
+
*
|
|
447
|
+
* Reads the per-channel lowercased name from the joiner's `nameLower`
|
|
448
|
+
* field; services-store keys are rfc1459-folded so the lookup is
|
|
449
|
+
* case-insensitive.
|
|
450
|
+
*/
|
|
451
|
+
function restoreKeepTopic(ctx: Ctx, nameLower: string): ChannelTopic | undefined {
|
|
452
|
+
const services = ctx.services;
|
|
453
|
+
if (services === undefined) return undefined;
|
|
454
|
+
const rec = services.getChannel(nameLower);
|
|
455
|
+
if (rec === undefined || !rec.keepTopic) return undefined;
|
|
456
|
+
const persisted: PersistedTopic | undefined = services.getChannelTopic(nameLower);
|
|
457
|
+
if (persisted === undefined) return undefined;
|
|
458
|
+
return { text: persisted.text, setter: persisted.setter, setAt: persisted.setAt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ============================================================================
|
|
462
|
+
// ChanServ ACCESS auto-prefix
|
|
463
|
+
// ============================================================================
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Decides whether ChanServ should auto-grant a channel prefix to the
|
|
467
|
+
* joining connection on a non-empty registered channel.
|
|
468
|
+
*
|
|
469
|
+
* Returns `{ op: true, voice: false }` for the channel founder or any
|
|
470
|
+
* account whose access level meets the founder-configured `AUTOOP`
|
|
471
|
+
* threshold; `{ op: false, voice: true }` for accounts that meet the
|
|
472
|
+
* `AUTOVOICE` threshold; `{ op: false, voice: false }` otherwise.
|
|
473
|
+
*
|
|
474
|
+
* Returns `null` when no services store is bound, the channel is not
|
|
475
|
+
* registered, or the joiner is unidentified — the caller treats all of
|
|
476
|
+
* those as "no ChanServ-derived prefix" and falls back to the RFC 2812
|
|
477
|
+
* first-joiner rule or the plain-roster defaults.
|
|
478
|
+
*
|
|
479
|
+
* Threshold lookup consults the founder-redefined overrides via
|
|
480
|
+
* {@link ServicesStore.getChannelLevel} and falls back to
|
|
481
|
+
* {@link DEFAULT_CHANNEL_LEVELS} for ops the founder has not overridden.
|
|
482
|
+
*/
|
|
483
|
+
function computeChanservAutoPrefix(
|
|
484
|
+
ctx: Ctx,
|
|
485
|
+
nameLower: string,
|
|
486
|
+
): { op: boolean; voice: boolean } | null {
|
|
487
|
+
const services = ctx.services;
|
|
488
|
+
if (services === undefined) return null;
|
|
489
|
+
const rec = services.getChannel(nameLower);
|
|
490
|
+
if (rec === undefined) return null;
|
|
491
|
+
const account = ctx.connection.account;
|
|
492
|
+
if (account === undefined) return null;
|
|
493
|
+
if (caseFold('rfc1459', rec.founder) === caseFold('rfc1459', account)) {
|
|
494
|
+
return { op: true, voice: false };
|
|
495
|
+
}
|
|
496
|
+
const level = services.getChannelAccess(nameLower, account);
|
|
497
|
+
const autoOp = services.getChannelLevel(nameLower, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
|
|
498
|
+
const autoVoice =
|
|
499
|
+
services.getChannelLevel(nameLower, 'AUTOVOICE') ?? DEFAULT_CHANNEL_LEVELS.AUTOVOICE;
|
|
500
|
+
if (level >= autoOp) return { op: true, voice: false };
|
|
501
|
+
if (level >= autoVoice) return { op: false, voice: true };
|
|
502
|
+
return { op: false, voice: false };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Builds the `Broadcast` effect for a ChanServ-emitted channel prefix
|
|
507
|
+
* grant. The line shape mirrors the MODE reducer's ChanServ-MLOCK
|
|
508
|
+
* reassert broadcast so existing clients already special-case
|
|
509
|
+
* `:ChanServ!ChanServ@services MODE …` correctly.
|
|
510
|
+
*
|
|
511
|
+
* Op grants emit `+o` only (the channel-prefix grammar renders `@` for
|
|
512
|
+
* ops, so a redundant `+v` would only confuse client UIs that show both
|
|
513
|
+
* prefixes side-by-side).
|
|
514
|
+
*/
|
|
515
|
+
function buildChanservPrefixBroadcast(
|
|
516
|
+
chanName: ChanName,
|
|
517
|
+
nick: string,
|
|
518
|
+
prefix: { op: boolean; voice: boolean },
|
|
519
|
+
): EffectType {
|
|
520
|
+
const mode = prefix.op ? '+o' : '+v';
|
|
521
|
+
return Effect.broadcast(chanName, [
|
|
522
|
+
{ text: `:${CHANSERV_HOSTMASK} MODE ${chanName} ${mode} ${nick}` },
|
|
523
|
+
]);
|
|
524
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `draft/read-marker` `MARKREAD` command.
|
|
3
|
+
*
|
|
4
|
+
* Spec (work-in-progress): https://ircv3.net/specs/extensions/read-marker
|
|
5
|
+
*
|
|
6
|
+
* The serverless-ircd deployment stores read markers **by msgid** (see
|
|
7
|
+
* {@link ../commands/read-marker.ts}) so they compose with the chathistory
|
|
8
|
+
* last-read plumbing. The draft `MARKREAD` verb, by contrast, is
|
|
9
|
+
* timestamp-based: a client sends `MARKREAD <target> <timestamp>` where the
|
|
10
|
+
* timestamp is the `time` tag of the last read message. This reducer is the
|
|
11
|
+
* translation layer — it resolves the client timestamp to the most recent
|
|
12
|
+
* retained message at or before it ({@link MessageStore.findAtOrBefore}),
|
|
13
|
+
* advances the msgid-based marker via {@link persistReadMarker}, and replies
|
|
14
|
+
* with the resolved message's timestamp so the client observes a value that
|
|
15
|
+
* corresponds to a real `time` tag.
|
|
16
|
+
*
|
|
17
|
+
* Wire forms handled:
|
|
18
|
+
* - `MARKREAD <target>` (GET) — replies with the stored marker's timestamp
|
|
19
|
+
* (or `*` when none / unresolvable).
|
|
20
|
+
* - `MARKREAD <target> <timestamp>` (SET) — advances the marker subject to
|
|
21
|
+
* monotonicity (the marker never moves backwards) and replies with the
|
|
22
|
+
* resolved timestamp, or with the stored value when the client timestamp
|
|
23
|
+
* is older-or-equal.
|
|
24
|
+
*
|
|
25
|
+
* Errors use the IRCv3 `standard-replies` `FAIL` form (the draft specifies no
|
|
26
|
+
* legacy numerics for `MARKREAD`):
|
|
27
|
+
* - `FAIL MARKREAD NEED_MORE_PARAMS :Missing parameters`
|
|
28
|
+
* - `FAIL MARKREAD INVALID_PARAMS :Invalid parameters`
|
|
29
|
+
*
|
|
30
|
+
* The command is gated behind the `draft/read-marker` capability: a client
|
|
31
|
+
* that did not negotiate it sees `421 ERR_UNKNOWNCOMMAND` (the spec's
|
|
32
|
+
* permitted "unknown command" fallback, matching the `TAGMSG` precedent).
|
|
33
|
+
*
|
|
34
|
+
* Membership mirrors the `TAGMSG` read-marker path: a non-member on a `+n`
|
|
35
|
+
* channel is ignored silently (the user has no marker to set or get).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Effect } from '../effects.js';
|
|
39
|
+
import type { RawLine } from '../effects.js';
|
|
40
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
41
|
+
import { formatServerTime } from '../protocol/outbound.js';
|
|
42
|
+
import type { ChannelState } from '../state/channel.js';
|
|
43
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
44
|
+
import { READ_MARKER_CAP, persistReadMarker } from './read-marker.js';
|
|
45
|
+
|
|
46
|
+
/** Prefix that introduces the server-time timestamp in a SET param. */
|
|
47
|
+
const TIMESTAMP_PREFIX = 'timestamp=';
|
|
48
|
+
|
|
49
|
+
/** Literal timestamp meaning "no known read marker" (server replies only). */
|
|
50
|
+
const NO_MARKER = '*';
|
|
51
|
+
|
|
52
|
+
/** Formats a numeric error line addressed to the connection's nick (or `*`). */
|
|
53
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
54
|
+
const nick = ctx.connection.nick ?? '*';
|
|
55
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
56
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Formats a `FAIL MARKREAD <code> :<desc>` standard-reply line. */
|
|
60
|
+
function failReply(ctx: Ctx, code: string, desc: string): RawLine {
|
|
61
|
+
return { text: `:${ctx.serverName} FAIL MARKREAD ${code} :${desc}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Formats the `:<server> MARKREAD <target> <timestamp>` server reply line. */
|
|
65
|
+
function markreadReply(ctx: Ctx, target: string, timestamp: string): RawLine {
|
|
66
|
+
return { text: `:${ctx.serverName} MARKREAD ${target} ${timestamp}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Formats `ms` as the wire timestamp token carried by every server
|
|
71
|
+
* `MARKREAD` reply: `timestamp=<server-time>` (matching the client SET form
|
|
72
|
+
* and the spec examples). `NO_MARKER` (`*`) is returned verbatim by callers
|
|
73
|
+
* and never passes through here.
|
|
74
|
+
*/
|
|
75
|
+
function timestampTag(ms: number): string {
|
|
76
|
+
return `${TIMESTAMP_PREFIX}${formatServerTime(ms)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parses a SET timestamp param (`timestamp=<iso>`) into epoch ms, or
|
|
81
|
+
* `undefined` when the param is malformed (missing prefix or an unparseable
|
|
82
|
+
* date). The literal `*` is rejected upstream (it is invalid for SET) before
|
|
83
|
+
* this helper runs.
|
|
84
|
+
*/
|
|
85
|
+
function parseTimestampParam(raw: string): number | undefined {
|
|
86
|
+
if (!raw.startsWith(TIMESTAMP_PREFIX)) return undefined;
|
|
87
|
+
const value = raw.slice(TIMESTAMP_PREFIX.length);
|
|
88
|
+
if (value.length === 0) return undefined;
|
|
89
|
+
const ms = Date.parse(value);
|
|
90
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the connection's current marker for `chanLower` to its message's
|
|
95
|
+
* epoch-ms time, or `undefined` when there is no marker, no bound message
|
|
96
|
+
* store, or the marker's message is no longer retained. The epoch-ms form
|
|
97
|
+
* drives the monotonicity comparison; {@link markerTimestampTag} formats the
|
|
98
|
+
* wire form for replies.
|
|
99
|
+
*/
|
|
100
|
+
function markerTime(
|
|
101
|
+
ctx: Ctx,
|
|
102
|
+
chanLower: string,
|
|
103
|
+
messages: MessageStoreLike | undefined,
|
|
104
|
+
): number | undefined {
|
|
105
|
+
const msgid = ctx.connection.lastReadMarkers?.get(chanLower);
|
|
106
|
+
if (msgid === undefined || messages === undefined) return undefined;
|
|
107
|
+
return messages.getMsg(chanLower, msgid)?.time;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface MessageStoreLike {
|
|
111
|
+
getMsg(chan: string, msgid: string): { time: number } | undefined;
|
|
112
|
+
findAtOrBefore(chan: string, ts: number): { msgid: string; time: number } | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reducer for `MARKREAD <channel>` (GET) and `MARKREAD <channel> <ts>` (SET). */
|
|
116
|
+
export const markreadChannelReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
117
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
118
|
+
|
|
119
|
+
// Cap gate: clients that did not negotiate draft/read-marker see RFC 2812
|
|
120
|
+
// only (the spec's permitted "unknown command" fallback).
|
|
121
|
+
if (!ctx.connection.caps.has(READ_MARKER_CAP)) {
|
|
122
|
+
return {
|
|
123
|
+
state,
|
|
124
|
+
effects: [
|
|
125
|
+
Effect.send(ctx.connId, [
|
|
126
|
+
numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'MARKREAD'),
|
|
127
|
+
]),
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const target = msg.params[0];
|
|
133
|
+
if (target === undefined || target.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
state,
|
|
136
|
+
effects: [
|
|
137
|
+
Effect.send(ctx.connId, [failReply(ctx, 'NEED_MORE_PARAMS', 'Missing parameters')]),
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Membership: a non-member on a +n channel has no marker to set or get.
|
|
143
|
+
// Silent, mirroring the TAGMSG read-marker path.
|
|
144
|
+
const senderEntry = state.members.get(ctx.connId);
|
|
145
|
+
if (state.modes.noExternal && senderEntry === undefined) {
|
|
146
|
+
return { state, effects: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const messages = ctx.messages as MessageStoreLike | undefined;
|
|
150
|
+
|
|
151
|
+
// GET command: reply with the stored marker timestamp (or *).
|
|
152
|
+
if (msg.params.length < 2) {
|
|
153
|
+
const stored = markerTime(ctx, state.nameLower, messages);
|
|
154
|
+
const tag = stored === undefined ? NO_MARKER : timestampTag(stored);
|
|
155
|
+
return { state, effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, tag)])] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// SET command.
|
|
159
|
+
const raw = msg.params[1] as string;
|
|
160
|
+
if (raw === NO_MARKER) {
|
|
161
|
+
// The literal * is reserved for server replies; a client SET MUST NOT use it.
|
|
162
|
+
return {
|
|
163
|
+
state,
|
|
164
|
+
effects: [Effect.send(ctx.connId, [failReply(ctx, 'INVALID_PARAMS', 'Invalid parameters')])],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const clientMs = parseTimestampParam(raw);
|
|
168
|
+
if (clientMs === undefined) {
|
|
169
|
+
return {
|
|
170
|
+
state,
|
|
171
|
+
effects: [Effect.send(ctx.connId, [failReply(ctx, 'INVALID_PARAMS', 'Invalid parameters')])],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Translate the client timestamp to the most recent retained message at or
|
|
176
|
+
// before it. Without a bound store, or when nothing matches (e.g. the
|
|
177
|
+
// timestamp predates every retained message), there is no msgid to advance
|
|
178
|
+
// to: reply with the current marker and do not mutate state.
|
|
179
|
+
const resolved = messages?.findAtOrBefore(state.nameLower, clientMs);
|
|
180
|
+
if (resolved === undefined) {
|
|
181
|
+
const stored = markerTime(ctx, state.nameLower, messages);
|
|
182
|
+
const tag = stored === undefined ? NO_MARKER : timestampTag(stored);
|
|
183
|
+
return { state, effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, tag)])] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Monotonicity: the marker only ever advances. A client timestamp that
|
|
187
|
+
// resolves to a message at or before the stored marker is rejected — the
|
|
188
|
+
// server replies with the stored (newer) value and ignores the client value.
|
|
189
|
+
const storedMs = markerTime(ctx, state.nameLower, messages);
|
|
190
|
+
if (storedMs !== undefined && resolved.time <= storedMs) {
|
|
191
|
+
return {
|
|
192
|
+
state,
|
|
193
|
+
effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, timestampTag(storedMs))])],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
persistReadMarker(ctx, state.nameLower, resolved.msgid);
|
|
198
|
+
return {
|
|
199
|
+
state,
|
|
200
|
+
effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, timestampTag(resolved.time))])],
|
|
201
|
+
};
|
|
202
|
+
};
|