serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `draft/chathistory` command.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/chathistory
|
|
5
|
+
*
|
|
6
|
+
* Surface 1 (explicit query): `CHATHISTORY <sub> <target> [<args>...]`. The
|
|
7
|
+
* reducer reads the backlog from the {@link MessageStore} injected via
|
|
8
|
+
* {@link Ctx} (mirroring `MotdProvider` / `AccountStore`) and emits the
|
|
9
|
+
* matching `PRIVMSG`/`NOTICE`/`TAGMSG` lines wrapped in a single
|
|
10
|
+
* `BATCH chathistory <target>` frame. Each replay line carries the original
|
|
11
|
+
* `@time=…` (server-time) and `msgid=…` tags so the client can de-dup and
|
|
12
|
+
* place it on the timeline.
|
|
13
|
+
*
|
|
14
|
+
* The cap `draft/chathistory` MUST be negotiated before any query is
|
|
15
|
+
* accepted; clients without the cap get `421 ERR_UNKNOWNCOMMAND` (the spec's
|
|
16
|
+
* permitted "unknown command" fallback).
|
|
17
|
+
*
|
|
18
|
+
* Location-of-authority: chathistory is read-only and runs against the
|
|
19
|
+
* requester's connection. The target channel is passed in (resolved by the
|
|
20
|
+
* actor layer); `undefined` means "no such channel" → `403`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Effect } from '../effects.js';
|
|
24
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
|
+
import type { StoredMessage } from '../ports.js';
|
|
26
|
+
import { wrapBatch } from '../protocol/batch.js';
|
|
27
|
+
import type { IrcMessage } from '../protocol/messages.js';
|
|
28
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
29
|
+
import { formatServerTime } from '../protocol/outbound.js';
|
|
30
|
+
import type { ChannelState } from '../state/channel.js';
|
|
31
|
+
import type { Ctx } from '../types.js';
|
|
32
|
+
|
|
33
|
+
/** The cap name advertising chathistory support. */
|
|
34
|
+
export const CHATHISTORY_CAP = 'draft/chathistory';
|
|
35
|
+
|
|
36
|
+
/** Default query limit when the client omits one (spec recommends 50). */
|
|
37
|
+
const DEFAULT_QUERY_LIMIT = 50;
|
|
38
|
+
|
|
39
|
+
/** Sentinel the client uses to mean "most recent" / "now" / "beginning". */
|
|
40
|
+
const WILDCARD = '*';
|
|
41
|
+
|
|
42
|
+
/** Subcommands that operate on a single channel target. */
|
|
43
|
+
type ChannelSubcommand = 'LATEST' | 'BEFORE' | 'AFTER' | 'AROUND' | 'BETWEEN';
|
|
44
|
+
|
|
45
|
+
const CHANNEL_SUBCOMMANDS: ReadonlySet<string> = new Set<string>([
|
|
46
|
+
'LATEST',
|
|
47
|
+
'BEFORE',
|
|
48
|
+
'AFTER',
|
|
49
|
+
'AROUND',
|
|
50
|
+
'BETWEEN',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handles `CHATHISTORY <sub> <target> [<args>...]`.
|
|
55
|
+
*
|
|
56
|
+
* @param channel The authoritative ChannelState for `msg.params[1]`, or
|
|
57
|
+
* `undefined` when no such channel exists (→ `403`).
|
|
58
|
+
*/
|
|
59
|
+
export function chathistoryReducer(
|
|
60
|
+
channel: ChannelState | undefined,
|
|
61
|
+
msg: IrcMessage,
|
|
62
|
+
ctx: Ctx,
|
|
63
|
+
): { effects: EffectType[] } {
|
|
64
|
+
// Cap gate: clients that did not negotiate chathistory see RFC 2812 only.
|
|
65
|
+
if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
|
|
66
|
+
return {
|
|
67
|
+
effects: [
|
|
68
|
+
Effect.send(ctx.connId, [
|
|
69
|
+
numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'CHATHISTORY'),
|
|
70
|
+
]),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sub = (msg.params[0] ?? '').toUpperCase();
|
|
76
|
+
if (sub === '') {
|
|
77
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (sub === 'TARGETS') {
|
|
81
|
+
return handleTargets(msg, ctx);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!CHANNEL_SUBCOMMANDS.has(sub)) {
|
|
85
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return handleChannelSub(sub as ChannelSubcommand, channel, msg, ctx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Handles the four channel-bound subcommands + BETWEEN. */
|
|
92
|
+
function handleChannelSub(
|
|
93
|
+
sub: ChannelSubcommand,
|
|
94
|
+
channel: ChannelState | undefined,
|
|
95
|
+
msg: IrcMessage,
|
|
96
|
+
ctx: Ctx,
|
|
97
|
+
): { effects: EffectType[] } {
|
|
98
|
+
const target = msg.params[1];
|
|
99
|
+
if (target === undefined) {
|
|
100
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (channel === undefined) {
|
|
104
|
+
return {
|
|
105
|
+
effects: [
|
|
106
|
+
Effect.send(ctx.connId, [
|
|
107
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', target),
|
|
108
|
+
]),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// +s/+p leak prevention: a non-member must not learn the channel has
|
|
114
|
+
// history. The spec says return an empty batch; we elide the batch entirely
|
|
115
|
+
// (empty batches are forbidden) so the requester observes nothing.
|
|
116
|
+
if ((channel.modes.secret || channel.modes.private) && !channel.members.has(ctx.connId)) {
|
|
117
|
+
return { effects: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const chanLower = channel.nameLower;
|
|
121
|
+
|
|
122
|
+
if (sub === 'BETWEEN') {
|
|
123
|
+
const lo = msg.params[2];
|
|
124
|
+
const hi = msg.params[3];
|
|
125
|
+
if (lo === undefined || hi === undefined) {
|
|
126
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
127
|
+
}
|
|
128
|
+
if (!storeHas(ctx, chanLower, lo) || !storeHas(ctx, chanLower, hi)) {
|
|
129
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
130
|
+
}
|
|
131
|
+
const result = storeQuery(ctx, {
|
|
132
|
+
chan: chanLower,
|
|
133
|
+
direction: 'between',
|
|
134
|
+
limit: DEFAULT_QUERY_LIMIT,
|
|
135
|
+
msgid: lo,
|
|
136
|
+
msgid2: hi,
|
|
137
|
+
});
|
|
138
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// LATEST / BEFORE / AFTER / AROUND
|
|
142
|
+
const pivot = msg.params[2];
|
|
143
|
+
const limitRaw = msg.params[3];
|
|
144
|
+
if (sub !== 'LATEST' && pivot === undefined) {
|
|
145
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
146
|
+
}
|
|
147
|
+
const limit = parseLimit(limitRaw);
|
|
148
|
+
if (limit === undefined) {
|
|
149
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (sub === 'LATEST') {
|
|
153
|
+
const pivotArg = pivot === undefined || pivot === WILDCARD ? undefined : pivot;
|
|
154
|
+
if (pivotArg !== undefined && !storeHas(ctx, chanLower, pivotArg)) {
|
|
155
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
156
|
+
}
|
|
157
|
+
const result = storeQuery(ctx, {
|
|
158
|
+
chan: chanLower,
|
|
159
|
+
direction: 'latest',
|
|
160
|
+
limit,
|
|
161
|
+
...(pivotArg !== undefined ? { msgid: pivotArg } : {}),
|
|
162
|
+
});
|
|
163
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// BEFORE / AFTER / AROUND — pivot is required. The `sub !== 'LATEST' &&
|
|
167
|
+
// pivot === undefined` guard above already rejected the missing-pivot
|
|
168
|
+
// case and LATEST returned early just above; assert non-null for the
|
|
169
|
+
// type-checker without an unreachable branch.
|
|
170
|
+
const pivotMsgid = pivot as string;
|
|
171
|
+
if (!storeHas(ctx, chanLower, pivotMsgid)) {
|
|
172
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
173
|
+
}
|
|
174
|
+
const result = storeQuery(ctx, {
|
|
175
|
+
chan: chanLower,
|
|
176
|
+
direction: sub.toLowerCase() as 'before' | 'after' | 'around',
|
|
177
|
+
limit,
|
|
178
|
+
msgid: pivotMsgid,
|
|
179
|
+
});
|
|
180
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Handles `CHATHISTORY TARGETS <since> <until>`. */
|
|
184
|
+
function handleTargets(msg: IrcMessage, ctx: Ctx): { effects: EffectType[] } {
|
|
185
|
+
const sinceRaw = msg.params[1];
|
|
186
|
+
const untilRaw = msg.params[2];
|
|
187
|
+
if (sinceRaw === undefined || untilRaw === undefined) {
|
|
188
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
189
|
+
}
|
|
190
|
+
const since = parseTimestamp(sinceRaw, 0);
|
|
191
|
+
const until = parseTimestamp(untilRaw, ctx.clock.now());
|
|
192
|
+
if (since === undefined || until === undefined) {
|
|
193
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entries = ctx.messages !== undefined ? ctx.messages.targets(since, until) : [];
|
|
197
|
+
if (entries.length === 0) {
|
|
198
|
+
return { effects: [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body: RawLine[] = entries.map((e) => ({
|
|
202
|
+
text: `:${ctx.serverName} CHATHISTORY TARGETS ${formatServerTime(e.time)} ${e.chan}`,
|
|
203
|
+
}));
|
|
204
|
+
const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory');
|
|
205
|
+
return { effects: [Effect.send(ctx.connId, frame)] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wraps `messages` as replay lines in a `BATCH chathistory <chan>` frame
|
|
210
|
+
* addressed to the requester. Empty input elides the batch entirely (the
|
|
211
|
+
* spec forbids empty batches; the client interprets "no batch" as "no
|
|
212
|
+
* history").
|
|
213
|
+
*
|
|
214
|
+
* Shared by the explicit `CHATHISTORY` query and the JOIN auto-playback hook
|
|
215
|
+
* so both surfaces emit identical framing.
|
|
216
|
+
*/
|
|
217
|
+
export function buildChathistoryBatch(
|
|
218
|
+
ctx: Ctx,
|
|
219
|
+
displayChan: string,
|
|
220
|
+
messages: readonly StoredMessage[],
|
|
221
|
+
): EffectType[] {
|
|
222
|
+
if (messages.length === 0) return [];
|
|
223
|
+
const body = messages.map((m) => formatReplayLine(m, displayChan));
|
|
224
|
+
const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory', displayChan);
|
|
225
|
+
return [Effect.send(ctx.connId, frame)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reconstructs the wire line for one stored message, decorated with the
|
|
230
|
+
* original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
|
|
231
|
+
* text; TAGMSG has no trailing parameter.
|
|
232
|
+
*/
|
|
233
|
+
export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
|
|
234
|
+
const stamp = formatServerTime(m.time);
|
|
235
|
+
const prefix = storedHostmask(m);
|
|
236
|
+
const tag = `@time=${stamp};msgid=${m.msgid}`;
|
|
237
|
+
if (m.command === 'TAGMSG') {
|
|
238
|
+
return { text: `${tag} :${prefix} TAGMSG ${displayChan}` };
|
|
239
|
+
}
|
|
240
|
+
return { text: `${tag} :${prefix} ${m.command} ${displayChan} :${m.text}` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Builds the `nick!user@host` source for a stored message. */
|
|
244
|
+
function storedHostmask(m: StoredMessage): string {
|
|
245
|
+
if (m.user === undefined && m.host === undefined) return m.nick;
|
|
246
|
+
let out = m.nick;
|
|
247
|
+
if (m.user !== undefined) out = `${out}!${m.user}`;
|
|
248
|
+
if (m.host !== undefined) out = `${out}@${m.host}`;
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Formats a numeric error line addressed to the connection's nick (or `*`). */
|
|
253
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
254
|
+
const nick = ctx.connection.nick ?? '*';
|
|
255
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
256
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `461 CHATHISTORY :Not enough parameters`. */
|
|
260
|
+
function needMoreParamsLine(ctx: Ctx): RawLine {
|
|
261
|
+
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CHATHISTORY');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `461 CHATHISTORY :Invalid parameters`. */
|
|
265
|
+
function invalidParamsLine(ctx: Ctx): RawLine {
|
|
266
|
+
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parses a non-negative integer limit; returns undefined on garbage. */
|
|
270
|
+
function parseLimit(raw: string | undefined): number | undefined {
|
|
271
|
+
if (raw === undefined) return DEFAULT_QUERY_LIMIT;
|
|
272
|
+
const n = Number(raw);
|
|
273
|
+
if (!Number.isInteger(n) || n < 0) return undefined;
|
|
274
|
+
return n;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parses a CHATHISTORY timestamp bound. `*` resolves to `wildcardValue`
|
|
279
|
+
* (0 for `since`, `ctx.clock.now()` for `until`); an ISO-8601 string parses
|
|
280
|
+
* to epoch ms; anything else returns undefined (→ `461`).
|
|
281
|
+
*/
|
|
282
|
+
function parseTimestamp(raw: string, wildcardValue: number): number | undefined {
|
|
283
|
+
if (raw === WILDCARD) return wildcardValue;
|
|
284
|
+
const ms = Date.parse(raw);
|
|
285
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** store.hasMsgid that treats an unbound store as "the msgid is not known". */
|
|
289
|
+
function storeHas(ctx: Ctx, chan: string, msgid: string): boolean {
|
|
290
|
+
return ctx.messages !== undefined ? ctx.messages.hasMsgid(chan, msgid) : false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** store.query that treats an unbound store as empty. */
|
|
294
|
+
function storeQuery(
|
|
295
|
+
ctx: Ctx,
|
|
296
|
+
q: {
|
|
297
|
+
chan: string;
|
|
298
|
+
direction: 'latest' | 'before' | 'after' | 'around' | 'between';
|
|
299
|
+
limit: number;
|
|
300
|
+
msgid?: string;
|
|
301
|
+
msgid2?: string;
|
|
302
|
+
},
|
|
303
|
+
): StoredMessage[] {
|
|
304
|
+
return ctx.messages !== undefined ? ctx.messages.query(q) : [];
|
|
305
|
+
}
|
|
@@ -18,6 +18,7 @@ export { quitReducer } from './quit.js';
|
|
|
18
18
|
export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
|
|
19
19
|
export { partReducer } from './part.js';
|
|
20
20
|
export { motdReducer } from './motd.js';
|
|
21
|
+
export { operReducer, matchOperCred } from './oper.js';
|
|
21
22
|
export {
|
|
22
23
|
isChannelTarget,
|
|
23
24
|
noticeChannelReducer,
|
|
@@ -38,3 +39,11 @@ export { capReducer } from './cap.js';
|
|
|
38
39
|
export { emitChghost } from './chghost.js';
|
|
39
40
|
export type { ChgHostInput } from './chghost.js';
|
|
40
41
|
export { awayReducer } from './away.js';
|
|
42
|
+
export { authenticateReducer } from './sasl.js';
|
|
43
|
+
export {
|
|
44
|
+
chathistoryReducer,
|
|
45
|
+
formatReplayLine,
|
|
46
|
+
buildChathistoryBatch,
|
|
47
|
+
CHATHISTORY_CAP,
|
|
48
|
+
} from './chathistory.js';
|
|
49
|
+
export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* case-insensitively against the roster (443 if already a member).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { caseFold } from '../case-fold.js';
|
|
23
24
|
import { Effect } from '../effects.js';
|
|
24
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
26
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -50,15 +51,11 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
|
|
|
50
51
|
return { text: parts.join(' ') };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
return nick.toLowerCase();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Finds a roster entry's connId by nick (case-insensitive). */
|
|
54
|
+
/** Finds a roster entry's connId by nick (case-insensitively, per `CASEMAPPING=rfc1459`). */
|
|
58
55
|
function findConnIdByNick(state: ChannelState, nick: string): string | null {
|
|
59
|
-
const target =
|
|
56
|
+
const target = caseFold('rfc1459', nick);
|
|
60
57
|
for (const entry of state.members.values()) {
|
|
61
|
-
if (
|
|
58
|
+
if (caseFold('rfc1459', entry.nick) === target) return entry.conn;
|
|
62
59
|
}
|
|
63
60
|
return null;
|
|
64
61
|
}
|
|
@@ -132,9 +129,9 @@ export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
132
129
|
return { state, effects };
|
|
133
130
|
}
|
|
134
131
|
|
|
135
|
-
// Success: record the pending invite (by
|
|
132
|
+
// Success: record the pending invite (by case-folded nick) so a later
|
|
136
133
|
// JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
|
|
137
|
-
state.pendingInvites.add(targetNick
|
|
134
|
+
state.pendingInvites.add(caseFold('rfc1459', targetNick));
|
|
138
135
|
|
|
139
136
|
const inviterNick = ctx.connection.nick ?? '*';
|
|
140
137
|
effects.push(
|
|
@@ -39,7 +39,7 @@ const PREFIX = '(ov)@+';
|
|
|
39
39
|
/** Channel name sigils this server recognises. */
|
|
40
40
|
const CHANTYPES = '#';
|
|
41
41
|
|
|
42
|
-
/** Case-mapping algorithm — v1 uses RFC 1459 (matches `
|
|
42
|
+
/** Case-mapping algorithm — v1 uses RFC 1459 (matches `case-fold.ts`). */
|
|
43
43
|
const CASEMAPPING = 'rfc1459';
|
|
44
44
|
|
|
45
45
|
/** Default max mode changes per single MODE command (PLAN §3). */
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
* channel.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { caseFold } from '../case-fold.js';
|
|
18
19
|
import { Effect } from '../effects.js';
|
|
19
20
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
21
|
+
import type { StoredMessage } from '../ports.js';
|
|
20
22
|
import { wrapBatch } from '../protocol/batch.js';
|
|
21
23
|
import { Numerics } from '../protocol/numerics.js';
|
|
22
24
|
import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
|
|
23
25
|
import { hostmaskOf } from '../state/connection.js';
|
|
24
26
|
import type { ConnectionState } from '../state/connection.js';
|
|
25
27
|
import type { Ctx, Reducer } from '../types.js';
|
|
28
|
+
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
@@ -64,16 +67,13 @@ function buildNamesList(state: ChannelState): string {
|
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* Formats a single numeric error line addressed to the connection's current
|
|
67
|
-
* nick (or `*` when unregistered).
|
|
68
|
-
*
|
|
70
|
+
* nick (or `*` when unregistered). `middle` is emitted before the trailing
|
|
71
|
+
* `:`-prefixed text.
|
|
69
72
|
*/
|
|
70
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
73
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
71
74
|
const nick = ctx.connection.nick ?? '*';
|
|
72
75
|
const codeStr = code.toString().padStart(3, '0');
|
|
73
|
-
|
|
74
|
-
if (middle !== undefined) parts.push(middle);
|
|
75
|
-
parts.push(`:${trailing}`);
|
|
76
|
-
return { text: parts.join(' ') };
|
|
76
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
@@ -107,6 +107,44 @@ function joinerHostmask(conn: ConnectionState): string {
|
|
|
107
107
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Default backlog depth replayed on JOIN when ServerConfig omits a value. */
|
|
111
|
+
const DEFAULT_CHATHISTORY_PLAYBACK_LIMIT = 50;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Builds the IRCv3 `draft/chathistory` auto-playback BATCH for a cap-enabled
|
|
115
|
+
* joiner and computes the next "last read" marker.
|
|
116
|
+
*
|
|
117
|
+
* Returns `{ effects: [], marker: undefined }` when playback is disabled (cap
|
|
118
|
+
* not negotiated, no MessageStore bound, or no messages newer than the
|
|
119
|
+
* marker); otherwise a single `Send` carrying the BATCH frame and the msgid
|
|
120
|
+
* of the newest replayed message. The caller is responsible for prepending
|
|
121
|
+
* the effects before the JOIN broadcast and for advancing the per-connection
|
|
122
|
+
* marker so a PART/re-JOIN cycle with no new activity replays nothing.
|
|
123
|
+
*/
|
|
124
|
+
function buildChathistoryPlayback(
|
|
125
|
+
state: ChannelState,
|
|
126
|
+
ctx: Ctx,
|
|
127
|
+
): { effects: EffectType[]; marker: string | undefined } {
|
|
128
|
+
if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
|
|
129
|
+
return { effects: [], marker: undefined };
|
|
130
|
+
}
|
|
131
|
+
if (ctx.messages === undefined) {
|
|
132
|
+
return { effects: [], marker: undefined };
|
|
133
|
+
}
|
|
134
|
+
const limit = ctx.serverConfig.chatHistoryPlaybackLimit ?? DEFAULT_CHATHISTORY_PLAYBACK_LIMIT;
|
|
135
|
+
const previousMarker = ctx.connection.lastReadMarkers?.get(state.nameLower);
|
|
136
|
+
const recent = ctx.messages.recent(state.nameLower, previousMarker, limit);
|
|
137
|
+
if (recent.length === 0) {
|
|
138
|
+
return { effects: [], marker: undefined };
|
|
139
|
+
}
|
|
140
|
+
// `recent` is non-empty here, so the last entry is defined; the assertion
|
|
141
|
+
// only satisfies the type-checker under `noUncheckedIndexedAccess` (no
|
|
142
|
+
// runtime branch, no `!` token).
|
|
143
|
+
const newest = recent[recent.length - 1] as StoredMessage;
|
|
144
|
+
const effects = buildChathistoryBatch(ctx, state.name, recent);
|
|
145
|
+
return { effects, marker: newest.msgid };
|
|
146
|
+
}
|
|
147
|
+
|
|
110
148
|
/**
|
|
111
149
|
* Handles `JOIN <chan> [<key>]` for a single channel.
|
|
112
150
|
*
|
|
@@ -176,9 +214,9 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
176
214
|
}
|
|
177
215
|
}
|
|
178
216
|
|
|
179
|
-
// Invite-only check. pendingInvites stores
|
|
217
|
+
// Invite-only check. pendingInvites stores case-folded nicks.
|
|
180
218
|
const myNick = ctx.connection.nick;
|
|
181
|
-
const invited = myNick !== undefined && state.pendingInvites.has(myNick
|
|
219
|
+
const invited = myNick !== undefined && state.pendingInvites.has(caseFold('rfc1459', myNick));
|
|
182
220
|
if (state.modes.inviteOnly && !invited) {
|
|
183
221
|
effects.push(
|
|
184
222
|
Effect.send(ctx.connId, [
|
|
@@ -221,7 +259,7 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
221
259
|
});
|
|
222
260
|
|
|
223
261
|
// Pending invite is consumed on a successful join.
|
|
224
|
-
if (myNick !== undefined) state.pendingInvites.delete(myNick
|
|
262
|
+
if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
|
|
225
263
|
|
|
226
264
|
// Track the channel on the connection's joined set (cross-authority).
|
|
227
265
|
ctx.connection.joinedChannels.add(state.nameLower);
|
|
@@ -244,6 +282,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
244
282
|
}),
|
|
245
283
|
);
|
|
246
284
|
|
|
285
|
+
// IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
|
|
286
|
+
// bound MessageStore receives the recent backlog as a BATCH *before*
|
|
287
|
+
// the JOIN broadcast, then the per-(connection, channel) "last read"
|
|
288
|
+
// marker advances to the newest replayed msgid. PART/re-JOIN cycles
|
|
289
|
+
// only replay messages newer than the marker, so a re-join with no
|
|
290
|
+
// new activity emits nothing.
|
|
291
|
+
const playback = buildChathistoryPlayback(state, ctx);
|
|
292
|
+
if (playback.marker !== undefined) {
|
|
293
|
+
if (ctx.connection.lastReadMarkers === undefined) {
|
|
294
|
+
ctx.connection.lastReadMarkers = new Map<string, string>();
|
|
295
|
+
}
|
|
296
|
+
ctx.connection.lastReadMarkers.set(state.nameLower, playback.marker);
|
|
297
|
+
}
|
|
298
|
+
effects.push(...playback.effects);
|
|
299
|
+
|
|
247
300
|
const legacyJoin: RawLine = { text: `:${hostmask} JOIN ${state.name}` };
|
|
248
301
|
const realname = ctx.connection.realname;
|
|
249
302
|
if (realname !== undefined) {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* one channel per invocation.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { caseFold } from '../case-fold.js';
|
|
16
17
|
import { Effect } from '../effects.js';
|
|
17
18
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
18
19
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -52,14 +53,6 @@ function kickerHostmask(conn: ConnectionState): string {
|
|
|
52
53
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Lowercases a nick using ASCII case folding (good enough for v1; rfc1459
|
|
57
|
-
* case mapping lands with the isupport `CASEMAPPING` token).
|
|
58
|
-
*/
|
|
59
|
-
function lowerNick(nick: string): string {
|
|
60
|
-
return nick.toLowerCase();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
56
|
/**
|
|
64
57
|
* Handles `KICK <chan> <user> [:reason]`.
|
|
65
58
|
*
|
|
@@ -117,11 +110,11 @@ export const kickReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
117
110
|
return { state, effects };
|
|
118
111
|
}
|
|
119
112
|
|
|
120
|
-
// Find the target by nick (case-
|
|
121
|
-
const targetLower =
|
|
113
|
+
// Find the target by nick (case-insensitively, per `CASEMAPPING=rfc1459`).
|
|
114
|
+
const targetLower = caseFold('rfc1459', targetNick);
|
|
122
115
|
let targetConnId: string | undefined;
|
|
123
116
|
for (const entry of state.members.values()) {
|
|
124
|
-
if (
|
|
117
|
+
if (caseFold('rfc1459', entry.nick) === targetLower) {
|
|
125
118
|
targetConnId = entry.conn;
|
|
126
119
|
break;
|
|
127
120
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* the cap).
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { caseFold } from '../case-fold.js';
|
|
29
30
|
import { Effect } from '../effects.js';
|
|
30
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
31
32
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -104,10 +105,10 @@ export const listReducer: Reducer<ChanSnapshot[]> = (state, msg, ctx) => {
|
|
|
104
105
|
for (const rawName of requested) {
|
|
105
106
|
if (lines.length >= max) break;
|
|
106
107
|
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) continue;
|
|
107
|
-
const
|
|
108
|
-
if (seen.has(
|
|
109
|
-
seen.add(
|
|
110
|
-
const chan = state.find((c) => c.nameLower ===
|
|
108
|
+
const key = caseFold('rfc1459', rawName);
|
|
109
|
+
if (seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const chan = state.find((c) => c.nameLower === key);
|
|
111
112
|
if (chan === undefined) continue;
|
|
112
113
|
if (!isVisible(chan, ctx)) continue;
|
|
113
114
|
lines.push(listLine(chan, ctx, nick));
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* for its own user modes, so the reducer's `state` argument is the
|
|
13
13
|
* caller's {@link ConnectionState}. A user may only change their own
|
|
14
14
|
* modes (502 otherwise). The oper flag (`+o`) is granted via the
|
|
15
|
-
*
|
|
15
|
+
* OPER command, not via MODE — `+o` here is rejected with 481.
|
|
16
16
|
*
|
|
17
17
|
* Mode classes (per modern IRC de-facto convention):
|
|
18
18
|
* - Type A (list mode, always takes arg): `b` (ban mask).
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* channel.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { caseFold } from '../case-fold.js';
|
|
31
32
|
import { Effect } from '../effects.js';
|
|
32
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -66,11 +67,6 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
|
|
|
66
67
|
return { text: parts.join(' ') };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/** Lowercases a nick using ASCII case folding (matches kick.ts). */
|
|
70
|
-
function lowerNick(nick: string): string {
|
|
71
|
-
return nick.toLowerCase();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
70
|
/** Boolean channel mode letters → mode-field name. */
|
|
75
71
|
const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
|
|
76
72
|
i: 'inviteOnly',
|
|
@@ -417,9 +413,9 @@ function findMemberByNick(
|
|
|
417
413
|
state: ChannelState,
|
|
418
414
|
nick: string,
|
|
419
415
|
): { conn: string; nick: string; op: boolean; voice: boolean } | null {
|
|
420
|
-
const targetLower =
|
|
416
|
+
const targetLower = caseFold('rfc1459', nick);
|
|
421
417
|
for (const entry of state.members.values()) {
|
|
422
|
-
if (
|
|
418
|
+
if (caseFold('rfc1459', entry.nick) === targetLower) {
|
|
423
419
|
return entry;
|
|
424
420
|
}
|
|
425
421
|
}
|
|
@@ -566,7 +562,7 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
566
562
|
|
|
567
563
|
// Target must be the caller's own nick (case-insensitive).
|
|
568
564
|
const myNick = state.nick;
|
|
569
|
-
if (myNick === undefined ||
|
|
565
|
+
if (myNick === undefined || caseFold('rfc1459', target) !== caseFold('rfc1459', myNick)) {
|
|
570
566
|
effects.push(
|
|
571
567
|
Effect.send(ctx.connId, [
|
|
572
568
|
numericErr(ctx, Numerics.ERR_USERSDONTMATCH, 'Cannot change mode for other users'),
|