serverless-ircd 0.9.0 → 0.11.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 +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -36,6 +36,14 @@
|
|
|
36
36
|
* threshold the JOIN hook compares against for the `AUTO*` ops.
|
|
37
37
|
* `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
|
|
38
38
|
* every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
|
|
39
|
+
* - `OP|DEOP|VOICE|DEVOICE <#channel> <nick>` — grant or strip a channel
|
|
40
|
+
* prefix via a ChanServ-sourced MODE broadcast (founder or
|
|
41
|
+
* AUTOOP-threshold access entry). DEOP additionally enforces a
|
|
42
|
+
* caller-vs-target rank guard: the target must not outrank the caller
|
|
43
|
+
* (see {@link rankOf} / {@link FOUNDER_RANK}), so an access-list entry
|
|
44
|
+
* cannot strip the founder — or any higher-ranked entry — mid-session.
|
|
45
|
+
* - `KICK <#channel> <nick> [:<reason>]` — removes a member (founder
|
|
46
|
+
* only) under the same target-rank guard.
|
|
39
47
|
*
|
|
40
48
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
41
49
|
* emitted as {@link Effect}s. Channel-mode mutations are emitted as
|
|
@@ -47,6 +55,7 @@ import { caseFold } from '../case-fold.js';
|
|
|
47
55
|
import { Effect } from '../effects.js';
|
|
48
56
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
49
57
|
import type { ChannelLevelOp, ServicesStore } from '../ports.js';
|
|
58
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
50
59
|
import type { ChannelDelta, Roster } from '../state/channel.js';
|
|
51
60
|
import type { ConnId, ConnectionState } from '../state/connection.js';
|
|
52
61
|
import type { Ctx, Reducer } from '../types.js';
|
|
@@ -60,15 +69,6 @@ const SERVICES_HOST = 'services';
|
|
|
60
69
|
/** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
|
|
61
70
|
export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
|
|
62
71
|
|
|
63
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
64
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
65
|
-
|
|
66
|
-
/** Returns true iff `name` looks like a channel target. */
|
|
67
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
68
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
69
|
-
return CHANNEL_NAME_RE.test(name);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
72
|
/** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
|
|
73
73
|
const ON_OFF = new Set<string>(['ON', 'OFF']);
|
|
74
74
|
|
|
@@ -111,6 +111,26 @@ export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', nu
|
|
|
111
111
|
/** Set of recognised shorthand verbs (uppercased). */
|
|
112
112
|
const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Rank of the channel founder in the DEOP/KICK caller-vs-target hierarchy.
|
|
116
|
+
* `Infinity` outranks every numeric access level, so no access-list entry
|
|
117
|
+
* can ever reach it — the only caller who can affect a founder-ranked
|
|
118
|
+
* target is the founder (an equal-rank, self-targeting action).
|
|
119
|
+
*/
|
|
120
|
+
export const FOUNDER_RANK = Number.POSITIVE_INFINITY;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Maps a numeric ChanServ access level to the rank ordering used by the
|
|
124
|
+
* DEOP/KICK target guard: founder ({@link FOUNDER_RANK}) > SOP (10) > AOP
|
|
125
|
+
* (5) > HOP (4) > VOP (3) > no entry (0). The mapping is the identity for
|
|
126
|
+
* positive levels today; it exists as the single seam where a future
|
|
127
|
+
* privilege tier (e.g. a distinct SOP rank scale) can be introduced
|
|
128
|
+
* without touching the comparison call sites.
|
|
129
|
+
*/
|
|
130
|
+
export function rankOf(level: number): number {
|
|
131
|
+
return level > 0 ? level : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
/** Ordered list of level op names, for LEVELS LIST and validation. */
|
|
115
135
|
const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
|
|
116
136
|
|
|
@@ -794,16 +814,24 @@ interface PrefixCommandOpts {
|
|
|
794
814
|
* - `founderOnly: true` → "Permission denied."
|
|
795
815
|
* - `founderOnly: false` → allowed when caller's access level meets the
|
|
796
816
|
* founder-configured `AUTOOP` threshold, otherwise "Permission denied."
|
|
817
|
+
* - Target-rank guard (`guardTargetRankOf`): the nick named by the command
|
|
818
|
+
* must not outrank the caller, otherwise "You do not have sufficient
|
|
819
|
+
* privileges on <chan> to affect <nick>." Used by DEOP and KICK so an
|
|
820
|
+
* access-list entry cannot strip the founder (or any higher-ranked
|
|
821
|
+
* entry) mid-session. Equal ranks — self-targeting and peers — are
|
|
822
|
+
* permitted; only a strictly higher-ranked target is protected.
|
|
797
823
|
*
|
|
798
824
|
* Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
|
|
799
|
-
* AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
|
|
825
|
+
* AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only. The
|
|
826
|
+
* target-rank guard applies only where the caller names a nick whose
|
|
827
|
+
* powers would be *removed* (DEOP/KICK), not where powers are granted.
|
|
800
828
|
*/
|
|
801
829
|
function requirePrivilegedChanServ(
|
|
802
830
|
state: ConnectionState,
|
|
803
831
|
channel: string,
|
|
804
832
|
ctx: Ctx,
|
|
805
833
|
effects: EffectType[],
|
|
806
|
-
opts: { founderOnly: boolean },
|
|
834
|
+
opts: { founderOnly: boolean; guardTargetRankOf?: string },
|
|
807
835
|
): ServicesStore | null {
|
|
808
836
|
const services = ctx.services as ServicesStore;
|
|
809
837
|
if (state.account === undefined) {
|
|
@@ -815,18 +843,89 @@ function requirePrivilegedChanServ(
|
|
|
815
843
|
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
816
844
|
return null;
|
|
817
845
|
}
|
|
818
|
-
|
|
819
|
-
|
|
846
|
+
const callerIsFounder = caseFold('rfc1459', founder) === caseFold('rfc1459', state.account);
|
|
847
|
+
if (callerIsFounder) {
|
|
848
|
+
return admitTargetRankGuard(state, services, FOUNDER_RANK, channel, founder, opts, effects);
|
|
820
849
|
}
|
|
821
850
|
if (!opts.founderOnly) {
|
|
822
851
|
const level = services.getChannelAccess(channel, state.account);
|
|
823
852
|
const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
|
|
824
|
-
if (level >= autoOp)
|
|
853
|
+
if (level >= autoOp) {
|
|
854
|
+
return admitTargetRankGuard(state, services, rankOf(level), channel, founder, opts, effects);
|
|
855
|
+
}
|
|
825
856
|
}
|
|
826
857
|
effects.push(notice(state, 'Permission denied.'));
|
|
827
858
|
return null;
|
|
828
859
|
}
|
|
829
860
|
|
|
861
|
+
/**
|
|
862
|
+
* Applies the DEOP/KICK target-rank guard at the tail of
|
|
863
|
+
* {@link requirePrivilegedChanServ}: the caller may affect the target nick
|
|
864
|
+
* only when the target's rank does not exceed theirs. Returns the store on
|
|
865
|
+
* success, `null` (with an insufficient-privileges NOTICE pushed) on
|
|
866
|
+
* failure.
|
|
867
|
+
*
|
|
868
|
+
* Rank rule: permitted iff `callerRank >= targetRank`. Equal ranks cover
|
|
869
|
+
* both self-targeting (the founder — or any access entry — releasing
|
|
870
|
+
* their own status) and peer-level actions (an AOP deopping a fellow
|
|
871
|
+
* AOP, who can simply re-op); any strictly higher-ranked target — the
|
|
872
|
+
* founder above every access entry, an SOP above an AOP — is protected.
|
|
873
|
+
*/
|
|
874
|
+
function admitTargetRankGuard(
|
|
875
|
+
state: ConnectionState,
|
|
876
|
+
services: ServicesStore,
|
|
877
|
+
callerRank: number,
|
|
878
|
+
channel: string,
|
|
879
|
+
founder: string,
|
|
880
|
+
opts: { founderOnly: boolean; guardTargetRankOf?: string },
|
|
881
|
+
effects: EffectType[],
|
|
882
|
+
): ServicesStore | null {
|
|
883
|
+
if (
|
|
884
|
+
opts.guardTargetRankOf !== undefined &&
|
|
885
|
+
callerRank < targetRankOf(services, channel, founder, opts.guardTargetRankOf)
|
|
886
|
+
) {
|
|
887
|
+
effects.push(
|
|
888
|
+
notice(
|
|
889
|
+
state,
|
|
890
|
+
`You do not have sufficient privileges on ${channel} to affect ${opts.guardTargetRankOf}.`,
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
return services;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Resolves the rank of a target NICK for the DEOP/KICK guard.
|
|
900
|
+
*
|
|
901
|
+
* The ChanServ reducer operates on the caller's ConnectionState and cannot
|
|
902
|
+
* see the connection behind the target nick, so the target's account is
|
|
903
|
+
* resolved via the nick registry (`getNick`):
|
|
904
|
+
* - a nick whose registered account matches the channel founder ranks as
|
|
905
|
+
* the founder ({@link FOUNDER_RANK}) and is protected from every
|
|
906
|
+
* non-founder caller;
|
|
907
|
+
* - any other registered nick ranks at its access-list level;
|
|
908
|
+
* - an unregistered (therefore unidentified) nick carries no account and
|
|
909
|
+
* ranks at the minimum.
|
|
910
|
+
*
|
|
911
|
+
* Documented simplification: protection is keyed on the nick's registered
|
|
912
|
+
* account, not on whether the connection currently using the nick is
|
|
913
|
+
* identified — the reducer has no way to observe the latter, and erring
|
|
914
|
+
* toward protection cannot be abused to escalate privileges.
|
|
915
|
+
*/
|
|
916
|
+
function targetRankOf(
|
|
917
|
+
services: ServicesStore,
|
|
918
|
+
channel: string,
|
|
919
|
+
founder: string,
|
|
920
|
+
targetNick: string,
|
|
921
|
+
): number {
|
|
922
|
+
const account = services.getNick(targetNick)?.account;
|
|
923
|
+
if (account !== undefined && caseFold('rfc1459', founder) === caseFold('rfc1459', account)) {
|
|
924
|
+
return FOUNDER_RANK;
|
|
925
|
+
}
|
|
926
|
+
return account === undefined ? rankOf(0) : rankOf(services.getChannelAccess(channel, account));
|
|
927
|
+
}
|
|
928
|
+
|
|
830
929
|
/**
|
|
831
930
|
* Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
|
|
832
931
|
*
|
|
@@ -862,6 +961,9 @@ function handlePrefixCommand(
|
|
|
862
961
|
|
|
863
962
|
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
864
963
|
founderOnly: false,
|
|
964
|
+
// Only DEOP removes powers, so only it carries the target-rank guard;
|
|
965
|
+
// OP/VOICE/DEVOICE never demote a higher-ranked user.
|
|
966
|
+
...(opts.verb === 'DEOP' ? { guardTargetRankOf: targetNick } : {}),
|
|
865
967
|
});
|
|
866
968
|
if (services === null) return { state, effects };
|
|
867
969
|
|
|
@@ -915,6 +1017,7 @@ function handleKick(
|
|
|
915
1017
|
|
|
916
1018
|
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
917
1019
|
founderOnly: true,
|
|
1020
|
+
guardTargetRankOf: targetNick,
|
|
918
1021
|
});
|
|
919
1022
|
if (services === null) return { state, effects };
|
|
920
1023
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* actor layer); `undefined` means "no such channel" → `403`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { DEFAULT_MAX_CHATHISTORY_LIMIT } from '../config.js';
|
|
23
24
|
import { Effect } from '../effects.js';
|
|
24
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
26
|
import type { StoredMessage } from '../ports.js';
|
|
@@ -154,7 +155,8 @@ function handleChannelSub(
|
|
|
154
155
|
if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
|
|
155
156
|
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
156
157
|
}
|
|
157
|
-
const
|
|
158
|
+
const maxLimit = ctx.serverConfig.chathistory?.maxLimit ?? DEFAULT_MAX_CHATHISTORY_LIMIT;
|
|
159
|
+
const limit = parseLimit(limitRaw, maxLimit);
|
|
158
160
|
if (limit === undefined) {
|
|
159
161
|
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
160
162
|
}
|
|
@@ -295,12 +297,18 @@ function invalidParamsLine(ctx: Ctx): RawLine {
|
|
|
295
297
|
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Parses a non-negative integer limit, silently capped at `maxLimit` (the
|
|
302
|
+
* deployment's chathistory ceiling); returns undefined on garbage. The cap
|
|
303
|
+
* keeps an oversized request (`CHATHISTORY LATEST #chan 999999999`) from
|
|
304
|
+
* asking the store to materialize a huge result set, regardless of the
|
|
305
|
+
* store's backing capacity.
|
|
306
|
+
*/
|
|
307
|
+
function parseLimit(raw: string | undefined, maxLimit: number): number | undefined {
|
|
308
|
+
if (raw === undefined) return Math.min(DEFAULT_QUERY_LIMIT, maxLimit);
|
|
301
309
|
const n = Number(raw);
|
|
302
310
|
if (!Number.isInteger(n) || n < 0) return undefined;
|
|
303
|
-
return n;
|
|
311
|
+
return Math.min(n, maxLimit);
|
|
304
312
|
}
|
|
305
313
|
|
|
306
314
|
/**
|
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
* peers see the revert. Requires identification.
|
|
16
16
|
* - `REQUEST <vhost>` — proposes a vhost for the account. Behaviour
|
|
17
17
|
* depends on {@link ServerConfig.hostservAutoApproveVhosts}: when the
|
|
18
|
-
* knob is
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* knob is off (the default) the request is queued for oper review via
|
|
19
|
+
* {@link ServicesStore.requestVhost} and the requester is told it is
|
|
20
|
+
* pending. When the knob is on (deprecated legacy auto-approve) the
|
|
21
|
+
* vhost is recorded against the account immediately, but only after
|
|
22
|
+
* clearing a built-in denylist (reserved labels such as `admin` /
|
|
23
|
+
* `staff` at any position, plus this server's own hostname) and
|
|
24
|
+
* matching the mandatory {@link ServerConfig.hostservAutoApproveAllowlist}
|
|
25
|
+
* (exact or `*.suffix` wildcard entries). Requires identification.
|
|
22
26
|
* - `SET <account> <vhost>` — oper-only. Records (or, with the literal
|
|
23
27
|
* `*` argument, clears) the vhost for `account` directly without
|
|
24
28
|
* going through REQUEST. The target account must be a registered nick.
|
|
@@ -67,6 +71,59 @@ function isValidVhost(vhost: string): boolean {
|
|
|
67
71
|
return VHOST_RE.test(vhost);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Labels a user may never self-assign via auto-approved REQUEST, at any
|
|
76
|
+
* dot-separated position of the vhost (case-insensitive — DNS labels are).
|
|
77
|
+
* These names impersonate staff, operators, or off-network services, so the
|
|
78
|
+
* denylist is enforced even when the operator explicitly allowlists them.
|
|
79
|
+
*/
|
|
80
|
+
const RESERVED_VHOST_LABELS = new Set(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security']);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Built-in denylist for auto-approved vhosts. Rejects any vhost containing
|
|
84
|
+
* a {@link RESERVED_VHOST_LABELS} label, plus the deployment's own hostname
|
|
85
|
+
* (exactly or as a suffix) so a user cannot cloak as
|
|
86
|
+
* `<anything>.<this-server>` and phish off its identity. Oper-vetted paths
|
|
87
|
+
* (SET, queue-mode APPROVE) intentionally bypass this — opers are trusted.
|
|
88
|
+
*/
|
|
89
|
+
function isDeniedVhost(vhost: string, serverName: string): boolean {
|
|
90
|
+
const lower = vhost.toLowerCase();
|
|
91
|
+
for (const label of lower.split('.')) {
|
|
92
|
+
if (RESERVED_VHOST_LABELS.has(label)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const server = serverName.toLowerCase();
|
|
97
|
+
return lower === server || lower.endsWith(`.${server}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Allowlist check for auto-approved vhosts. An entry matches when it is
|
|
102
|
+
* exactly the vhost (`users.example.net`) or a wildcard suffix
|
|
103
|
+
* (`*.users.example.com` matches `a.users.example.com` and any depth
|
|
104
|
+
* beneath it, but NOT the bare suffix). Matching is case-insensitive
|
|
105
|
+
* (DNS semantics). A missing allowlist matches nothing — the reducer
|
|
106
|
+
* fails closed; the config layer separately refuses to boot auto-approve
|
|
107
|
+
* mode without a non-empty allowlist.
|
|
108
|
+
*/
|
|
109
|
+
function isAllowlistedVhost(vhost: string, allowlist: ReadonlyArray<string> | undefined): boolean {
|
|
110
|
+
if (allowlist === undefined) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const lower = vhost.toLowerCase();
|
|
114
|
+
for (const entry of allowlist) {
|
|
115
|
+
const normalized = entry.toLowerCase();
|
|
116
|
+
if (normalized.startsWith('*.')) {
|
|
117
|
+
if (lower.endsWith(normalized.slice(1))) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
} else if (lower === normalized) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
/**
|
|
71
128
|
* Reducer for `PRIVMSG HostServ :<subcommand> <args…>`.
|
|
72
129
|
* Authority: the invoking connection's {@link ConnectionState}.
|
|
@@ -185,13 +242,20 @@ function handleOff(
|
|
|
185
242
|
/**
|
|
186
243
|
* Handles `REQUEST <vhost>`. Behaviour is selected by
|
|
187
244
|
* {@link ServerConfig.hostservAutoApproveVhosts}:
|
|
188
|
-
* - `true` (auto-approve) — the vhost is recorded against the account
|
|
189
|
-
* immediately (the path that shipped before the oper queue landed). The
|
|
190
|
-
* user is notified that the vhost has been requested and approved.
|
|
191
245
|
* - `false` or omitted (the default — queue mode) — the request is
|
|
192
246
|
* queued for oper review via {@link ServicesStore.requestVhost}; the
|
|
193
247
|
* vhost is NOT activated and no `CHGHOST` is emitted. The user is
|
|
194
|
-
* notified that the request is pending oper approval.
|
|
248
|
+
* notified that the request is pending oper approval. Oper-vetted
|
|
249
|
+
* vhosts are exempt from the denylist/allowlist below.
|
|
250
|
+
* - `true` (auto-approve, deprecated) — the vhost is recorded against
|
|
251
|
+
* the account immediately, but only if it survives hardening: the
|
|
252
|
+
* built-in denylist ({@link isDeniedVhost}: reserved labels at any
|
|
253
|
+
* position + this deployment's own hostname) rejects impersonation
|
|
254
|
+
* vectors even when the operator allowlisted them, and the mandatory
|
|
255
|
+
* allowlist ({@link isAllowlistedVhost}) must match the vhost exactly
|
|
256
|
+
* or via a `*.suffix` wildcard entry. Config parsing refuses to boot
|
|
257
|
+
* auto-approve mode without a non-empty allowlist; the reducer fails
|
|
258
|
+
* closed regardless.
|
|
195
259
|
*
|
|
196
260
|
* Identified-only — otherwise a guest could plant a vhost (or a pending
|
|
197
261
|
* request) against a registered account they do not own.
|
|
@@ -225,6 +289,18 @@ function handleRequest(
|
|
|
225
289
|
return { state, effects };
|
|
226
290
|
}
|
|
227
291
|
|
|
292
|
+
if (isDeniedVhost(vhost, ctx.serverName)) {
|
|
293
|
+
effects.push(
|
|
294
|
+
notice(state, `Vhost ${vhost} is not permitted (reserved label or this server's hostname).`),
|
|
295
|
+
);
|
|
296
|
+
return { state, effects };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!isAllowlistedVhost(vhost, ctx.serverConfig.hostservAutoApproveAllowlist)) {
|
|
300
|
+
effects.push(notice(state, `Vhost ${vhost} is not in the auto-approve allowlist.`));
|
|
301
|
+
return { state, effects };
|
|
302
|
+
}
|
|
303
|
+
|
|
228
304
|
services.setVhost(state.account, vhost);
|
|
229
305
|
effects.push(notice(state, `Vhost ${vhost} has been requested and approved.`));
|
|
230
306
|
return { state, effects };
|
|
@@ -15,7 +15,7 @@ export {
|
|
|
15
15
|
userReducer,
|
|
16
16
|
} from './registration.js';
|
|
17
17
|
export { quitReducer } from './quit.js';
|
|
18
|
-
export { handleJoinZero,
|
|
18
|
+
export { handleJoinZero, joinReducer } from './join.js';
|
|
19
19
|
export { partReducer } from './part.js';
|
|
20
20
|
export { motdReducer } from './motd.js';
|
|
21
21
|
export { operReducer, matchOperCred } from './oper.js';
|
|
@@ -61,6 +61,7 @@ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
|
61
61
|
export { markreadChannelReducer } from './markread.js';
|
|
62
62
|
export {
|
|
63
63
|
DEFAULT_MULTILINE_MAX_BYTES,
|
|
64
|
+
DEFAULT_MULTILINE_MAX_ENTRIES,
|
|
64
65
|
MULTILINE_CAP,
|
|
65
66
|
type FinalizeMultilineResult,
|
|
66
67
|
type MultilineEntry,
|
|
@@ -23,19 +23,13 @@
|
|
|
23
23
|
import { caseFold } from '../case-fold.js';
|
|
24
24
|
import { Effect } from '../effects.js';
|
|
25
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
26
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
26
27
|
import { Numerics } from '../protocol/numerics.js';
|
|
27
28
|
import type { ChannelState } from '../state/channel.js';
|
|
28
29
|
import { hostmaskOf } from '../state/connection.js';
|
|
29
30
|
import type { ConnectionState } from '../state/connection.js';
|
|
30
31
|
import type { Ctx, Reducer } from '../types.js';
|
|
31
32
|
|
|
32
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
33
|
-
|
|
34
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
35
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
36
|
-
return CHANNEL_NAME_RE.test(name);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
40
34
|
function callerHostmask(conn: ConnectionState): string {
|
|
41
35
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -20,6 +20,7 @@ import { Effect } from '../effects.js';
|
|
|
20
20
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
21
21
|
import type { PersistedTopic, StoredMessage } from '../ports.js';
|
|
22
22
|
import { wrapBatch } from '../protocol/batch.js';
|
|
23
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
23
24
|
import { Numerics } from '../protocol/numerics.js';
|
|
24
25
|
import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
|
|
25
26
|
import { hostmaskOf } from '../state/connection.js';
|
|
@@ -29,22 +30,6 @@ import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
|
|
|
29
30
|
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
30
31
|
import { topicNumerics } from './topic.js';
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
34
|
-
* prefix (`#` or `&`); the rest may be any printable char except space, comma,
|
|
35
|
-
* or colon (which all carry protocol meaning).
|
|
36
|
-
*/
|
|
37
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Returns true iff `name` matches the IRC channel-name grammar and is within
|
|
41
|
-
* the configured length cap.
|
|
42
|
-
*/
|
|
43
|
-
export function isValidChannelName(name: string, maxLen: number): boolean {
|
|
44
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
45
|
-
return CHANNEL_NAME_RE.test(name);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
33
|
/**
|
|
49
34
|
* Returns the 353 NAMES sigil for a channel given its modes:
|
|
50
35
|
* `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
|
|
@@ -16,20 +16,13 @@
|
|
|
16
16
|
import { caseFold } from '../case-fold.js';
|
|
17
17
|
import { Effect } from '../effects.js';
|
|
18
18
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
19
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
19
20
|
import { Numerics } from '../protocol/numerics.js';
|
|
20
21
|
import type { ChannelState } from '../state/channel.js';
|
|
21
22
|
import { hostmaskOf } from '../state/connection.js';
|
|
22
23
|
import type { ConnectionState } from '../state/connection.js';
|
|
23
24
|
import type { Ctx, Reducer } from '../types.js';
|
|
24
25
|
|
|
25
|
-
/** Channel-name validation mirrors JOIN: same grammar, same length cap. */
|
|
26
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
27
|
-
|
|
28
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
29
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
30
|
-
return CHANNEL_NAME_RE.test(name);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
26
|
/**
|
|
34
27
|
* Formats a single numeric error line addressed to the connection's current
|
|
35
28
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
@@ -29,18 +29,11 @@
|
|
|
29
29
|
import { caseFold } from '../case-fold.js';
|
|
30
30
|
import { Effect } from '../effects.js';
|
|
31
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
32
33
|
import { Numerics } from '../protocol/numerics.js';
|
|
33
34
|
import type { ChanSnapshot } from '../state/channel.js';
|
|
34
35
|
import type { Ctx, Reducer } from '../types.js';
|
|
35
36
|
|
|
36
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
37
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
38
|
-
|
|
39
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
40
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
41
|
-
return CHANNEL_NAME_RE.test(name);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
/**
|
|
45
38
|
* Returns true iff the requester is permitted to see `chan` in LIST output.
|
|
46
39
|
* Secret channels require membership; everything else is public.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
21
21
|
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
22
|
-
* the {@link
|
|
22
|
+
* the {@link MessageStore} pattern).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { caseFold } from '../case-fold.js';
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { caseFold } from '../case-fold.js';
|
|
32
32
|
import { Effect } from '../effects.js';
|
|
33
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
34
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
34
35
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
36
|
import type { BooleanChannelMode, ChannelState } from '../state/channel.js';
|
|
36
37
|
import type {
|
|
@@ -45,14 +46,6 @@ import type { ConnectionState } from '../state/connection.js';
|
|
|
45
46
|
import type { Ctx, Reducer } from '../types.js';
|
|
46
47
|
import { positiveMlockLetters } from './chanserv.js';
|
|
47
48
|
|
|
48
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
49
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
50
|
-
|
|
51
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
52
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
53
|
-
return CHANNEL_NAME_RE.test(name);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
57
50
|
function callerHostmask(conn: ConnectionState): string {
|
|
58
51
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_MULTILINE_MAX_BYTES,
|
|
29
|
+
DEFAULT_MULTILINE_MAX_ENTRIES,
|
|
29
30
|
MULTILINE_CAP_NAME,
|
|
30
31
|
multilineCapValue,
|
|
31
32
|
} from '../caps/capabilities.js';
|
|
32
33
|
import { Effect } from '../effects.js';
|
|
33
34
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
|
+
import { byteLength } from '../protocol/bytes.js';
|
|
34
36
|
import { hostmaskOf } from '../state/connection.js';
|
|
35
37
|
import type { Ctx } from '../types.js';
|
|
36
38
|
|
|
@@ -41,7 +43,7 @@ import type { Ctx } from '../types.js';
|
|
|
41
43
|
*/
|
|
42
44
|
export const MULTILINE_CAP = MULTILINE_CAP_NAME;
|
|
43
45
|
|
|
44
|
-
export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
|
|
46
|
+
export { DEFAULT_MULTILINE_MAX_BYTES, DEFAULT_MULTILINE_MAX_ENTRIES, multilineCapValue };
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* One accumulated inner line of a multi-line batch. The actor collects these
|
|
@@ -79,21 +81,13 @@ export function multilineByteLength(entries: readonly MultilineEntry[]): number
|
|
|
79
81
|
if (entries.length === 0) return 0;
|
|
80
82
|
let total = 0;
|
|
81
83
|
for (const entry of entries) {
|
|
82
|
-
total +=
|
|
84
|
+
total += byteLength(entry.text);
|
|
83
85
|
}
|
|
84
86
|
// `(n - 1)` separators join `n` entries.
|
|
85
87
|
total += entries.length - 1;
|
|
86
88
|
return total;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
|
|
90
|
-
const TEXT_ENCODER = new TextEncoder();
|
|
91
|
-
|
|
92
|
-
/** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
|
|
93
|
-
function utf8ByteLength(s: string): number {
|
|
94
|
-
return TEXT_ENCODER.encode(s).length;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
91
|
/** Resolves the sender's hostmask, falling back to nick then `?`. */
|
|
98
92
|
function senderHostmask(conn: Ctx['connection']): string {
|
|
99
93
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -31,17 +31,22 @@
|
|
|
31
31
|
import { Effect } from '../effects.js';
|
|
32
32
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
33
|
import { wrapBatch } from '../protocol/batch.js';
|
|
34
|
+
import { byteLength } from '../protocol/bytes.js';
|
|
35
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
34
36
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
37
|
import type { ChannelState, RosterEntry } from '../state/channel.js';
|
|
36
38
|
import type { Ctx, Reducer } from '../types.js';
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
|
|
42
|
+
* CR-LF. Transports append `\r\n` after the text produced here, so the
|
|
43
|
+
* budget for the line text itself is 512 − 2 = 510 bytes. Declared locally
|
|
44
|
+
* (mirroring `cap.ts` / `motd-lines.ts`) to keep this module self-contained.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_LINE_BYTES = 512;
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return CHANNEL_NAME_RE.test(name);
|
|
44
|
-
}
|
|
48
|
+
/** Length of the trailing CR-LF the wire layer appends to each line. */
|
|
49
|
+
const TRAILING_CRLF_LEN = 2;
|
|
45
50
|
|
|
46
51
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
47
52
|
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
@@ -90,14 +95,50 @@ function prefixFormatter(ctx: Ctx): (entry: RosterEntry) => string {
|
|
|
90
95
|
return ctx.connection.caps.has('multi-prefix') ? allPrefixes : highestPrefix;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Greedy-packs the formatted member list into one or more `353 RPL_NAMREPLY`
|
|
100
|
+
* lines so every emitted line fits the 510-byte text budget (RFC 2812 §3.2.5:
|
|
101
|
+
* long NAMES lists are split across multiple `353` replies rather than
|
|
102
|
+
* truncated mid-nick by the outbound line limiter).
|
|
103
|
+
*
|
|
104
|
+
* The full line is measured: the fixed `:{server} 353 {nick} {sigil} {chan} :`
|
|
105
|
+
* header, each member's mode-prefix bytes (`@`/`+`), the nicks themselves
|
|
106
|
+
* (UTF-8 bytes, not UTF-16 code units), and the separating spaces. A member
|
|
107
|
+
* that alone overflows the budget (only possible with pathological config —
|
|
108
|
+
* nicks are capped at `nickLen`) still gets its own line rather than being
|
|
109
|
+
* dropped, leaving any last-resort truncation to the outbound limiter.
|
|
110
|
+
*
|
|
111
|
+
* A channel with no members still yields exactly one `353` with an empty
|
|
112
|
+
* trailing parameter, so the reply shape (`353`…`366`) is stable for clients.
|
|
113
|
+
*/
|
|
114
|
+
function packNamesLines(state: ChannelState, ctx: Ctx, nick: string, sigil: string): RawLine[] {
|
|
95
115
|
const format = prefixFormatter(ctx);
|
|
96
|
-
const
|
|
116
|
+
const header = `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :`;
|
|
117
|
+
const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
|
|
118
|
+
const headerBytes = byteLength(header);
|
|
119
|
+
|
|
120
|
+
const lines: RawLine[] = [];
|
|
121
|
+
let current = '';
|
|
122
|
+
let usedBytes = 0;
|
|
123
|
+
let empty = true;
|
|
124
|
+
|
|
97
125
|
for (const entry of state.members.values()) {
|
|
98
|
-
|
|
126
|
+
const token = format(entry) + entry.nick;
|
|
127
|
+
const tokenBytes = byteLength(token);
|
|
128
|
+
// One extra byte for the space separator when appending to a non-empty list.
|
|
129
|
+
const costBytes = empty ? tokenBytes : tokenBytes + 1;
|
|
130
|
+
if (!empty && headerBytes + usedBytes + costBytes > budget) {
|
|
131
|
+
lines.push({ text: `${header}${current}` });
|
|
132
|
+
current = token;
|
|
133
|
+
usedBytes = tokenBytes;
|
|
134
|
+
} else {
|
|
135
|
+
current = empty ? token : `${current} ${token}`;
|
|
136
|
+
usedBytes += costBytes;
|
|
137
|
+
}
|
|
138
|
+
empty = false;
|
|
99
139
|
}
|
|
100
|
-
|
|
140
|
+
lines.push({ text: `${header}${current}` });
|
|
141
|
+
return lines;
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
/**
|
|
@@ -143,9 +184,8 @@ export const namesReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
143
184
|
|
|
144
185
|
const nick = ctx.connection.nick ?? '*';
|
|
145
186
|
const sigil = namesSigil(state);
|
|
146
|
-
const names = buildNamesList(state, ctx);
|
|
147
187
|
const replyLines: RawLine[] = [
|
|
148
|
-
|
|
188
|
+
...packNamesLines(state, ctx, nick, sigil),
|
|
149
189
|
{ text: `:${ctx.serverName} 366 ${nick} ${state.name} :End of /NAMES list.` },
|
|
150
190
|
];
|
|
151
191
|
|