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
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - `OPER <name> <password>` — authenticates and (on success) grants oper.
|
|
17
17
|
* - `381 RPL_YOUREOPER`:
|
|
18
18
|
* `:<server> 381 <nick> :You are now an IRC operator`
|
|
19
|
+
* - `451 ERR_NOTREGISTERED`:
|
|
20
|
+
* `:<server> 451 <nick> :You have not registered`
|
|
19
21
|
* - `461 ERR_NEEDMOREPARAMS`:
|
|
20
22
|
* `:<server> 461 <nick> OPER :Not enough parameters`
|
|
21
23
|
* - `464 ERR_PASSWDMISMATCH`:
|
|
@@ -24,25 +26,61 @@
|
|
|
24
26
|
* `:<server> 491 <nick> :No O-lines for your host`
|
|
25
27
|
*
|
|
26
28
|
* Behaviour:
|
|
29
|
+
* - Connection not yet registered (`state.registration !==
|
|
30
|
+
* 'registered'`) → `451 ERR_NOTREGISTERED`, mirroring the SETNAME
|
|
31
|
+
* gate. A pre-registration client has no nick yet, so an oper grant
|
|
32
|
+
* here would produce an unattributable `nick === undefined` oper
|
|
33
|
+
* (WHOIS/audit hostmasks degrade); the gate also keeps the lockout
|
|
34
|
+
* counters and credential store untouched for unregistered callers.
|
|
27
35
|
* - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
|
|
28
36
|
* - Already an operator → graceful idempotent no-op that re-confirms with
|
|
29
37
|
* `381` (no re-authentication required; the connection is already privy).
|
|
30
38
|
* No `MODE` echo is emitted because the user mode did not change.
|
|
31
39
|
* - No operator credentials configured (absent or empty) →
|
|
32
40
|
* `491 ERR_NOOPERHOST`.
|
|
41
|
+
* - Per-IP lockout tripped (see below) → `491 ERR_NOOPERHOST` WITHOUT
|
|
42
|
+
* consulting the credential store, plus a `BroadcastOperNotice` effect
|
|
43
|
+
* (`:<server> NOTICE * :OPER lockout triggered for <host>`) so every
|
|
44
|
+
* online oper learns a brute-force attempt is being throttled.
|
|
33
45
|
* - Matching credential pair → grant oper, emit `381`, then emit a
|
|
34
46
|
* `:<hostmask> MODE <nick> +o` notification to the connection so the
|
|
35
47
|
* client learns its user mode changed (mirrors the user `MODE` echo).
|
|
36
48
|
* - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
|
|
49
|
+
*
|
|
50
|
+
* Brute-force throttle (per-IP lockout):
|
|
51
|
+
* When a `ctx.operFailures` tracker is bound (the runtime owns one
|
|
52
|
+
* shared counter, `AdmissionStats`-style), every `464` records a
|
|
53
|
+
* failure for the connection's source host and every success resets it.
|
|
54
|
+
* Once `serverConfig.oper.maxFailures` failures (default 3) accumulate
|
|
55
|
+
* within `oper.failureWindowSeconds` (default 300s), subsequent `OPER`
|
|
56
|
+
* from that host are short-circuited above — the credential store (and
|
|
57
|
+
* its scrypt cost) is never consulted for a locked-out host, so online
|
|
58
|
+
* guessing is capped at maxFailures attempts per window per host.
|
|
59
|
+
*
|
|
60
|
+
* Lockout key: `state.host`. `ConnectionState` carries no dedicated IP
|
|
61
|
+
* field; transports stamp the peer address into `host` at connect time
|
|
62
|
+
* (AWS NLB / raw TCP supply the source IP verbatim). With cloaking
|
|
63
|
+
* enabled `host` holds the deterministic cloak — a pure function of the
|
|
64
|
+
* real IP — so per-IP grouping still holds; a HostServ vhost active on
|
|
65
|
+
* the connection re-keys that one connection. A connection with no host
|
|
66
|
+
* set is never tracked (no key → no counter → the credential gate alone
|
|
67
|
+
* applies).
|
|
37
68
|
*/
|
|
38
69
|
|
|
70
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
71
|
+
import { DEFAULT_OPER_FAILURE_WINDOW_SECONDS, DEFAULT_OPER_MAX_FAILURES } from '../config.js';
|
|
72
|
+
import {
|
|
73
|
+
type HashedAccountCredential,
|
|
74
|
+
hashAccountCredential,
|
|
75
|
+
verifyHashedPassword,
|
|
76
|
+
} from '../credential-hashing.js';
|
|
39
77
|
import { Effect } from '../effects.js';
|
|
40
78
|
import type { RawLine } from '../effects.js';
|
|
41
79
|
import { Numerics } from '../protocol/numerics.js';
|
|
42
80
|
import { hostmaskOf } from '../state/connection.js';
|
|
43
81
|
import type { ConnectionState } from '../state/connection.js';
|
|
44
|
-
import type { OperCred } from '../types.js';
|
|
45
|
-
import type { Ctx, Reducer } from '../types.js';
|
|
82
|
+
import type { OperCred, OperCredHashed, OperCredPlaintext } from '../types.js';
|
|
83
|
+
import type { Ctx, Reducer, ReducerResult } from '../types.js';
|
|
46
84
|
|
|
47
85
|
/** Fixed text emitted for `381 RPL_YOUREOPER`. */
|
|
48
86
|
const YOUREOPER_TRAILING_TEXT = 'You are now an IRC operator';
|
|
@@ -56,18 +94,235 @@ const NOOPERHOST_TRAILING_TEXT = 'No O-lines for your host';
|
|
|
56
94
|
/** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
|
|
57
95
|
const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
|
|
58
96
|
|
|
97
|
+
/** Fixed text emitted for `451 ERR_NOTREGISTERED` (matches `SETNAME`). */
|
|
98
|
+
const NOTREGISTERED_TRAILING_TEXT = 'You have not registered';
|
|
99
|
+
|
|
100
|
+
/** Fixed prefix of the oper-notice broadcast when the lockout gate trips. */
|
|
101
|
+
const OPER_LOCKOUT_NOTICE_PREFIX = 'OPER lockout triggered for';
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Hashed-oper credential verify — scrypt + timing-safe user compare.
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fixed salt used to bake the precomputed fixture entries below. 16 bytes
|
|
109
|
+
* of `0x42` so the resulting scrypt derivations are deterministic across
|
|
110
|
+
* Node versions and test runs (the salt is never used to verify real
|
|
111
|
+
* attacker traffic — only the fixture entries).
|
|
112
|
+
*/
|
|
113
|
+
const OPER_FIXTURE_SALT = new Uint8Array(16).fill(0x42);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* User name baked into the precomputed dummy hashed entry. Never matches a
|
|
117
|
+
* real oper name (contains a NUL) so the dummy verify never collides with
|
|
118
|
+
* a configured credential.
|
|
119
|
+
*/
|
|
120
|
+
const OPER_DUMMY_USER = '\u0000oper-timing-dummy';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Plaintext password baked into the precomputed dummy hashed entry. Never
|
|
124
|
+
* matches a real oper password (contains a NUL) so the dummy verify always
|
|
125
|
+
* fails — its purpose is to spend the same scrypt work as the
|
|
126
|
+
* known-user-with-wrong-password path, NOT to authenticate.
|
|
127
|
+
*/
|
|
128
|
+
const OPER_DUMMY_PASSWORD = '\u0000oper-timing-dummy';
|
|
129
|
+
|
|
130
|
+
/** User name baked into {@link OPER_PRECOMPUTED_HASHED_CRED}. */
|
|
131
|
+
export const OPER_HASHED_USER = 'oper-hashed-fixture-user';
|
|
132
|
+
|
|
133
|
+
/** Password that verifies against {@link OPER_PRECOMPUTED_HASHED_CRED}. */
|
|
134
|
+
export const OPER_HASHED_VALID_PASSWORD = 'oper-hashed-fixture-password';
|
|
135
|
+
|
|
136
|
+
/** Password that does NOT verify against {@link OPER_PRECOMPUTED_HASHED_CRED}. */
|
|
137
|
+
export const OPER_HASHED_WRONG_PASSWORD = 'definitely-not-the-right-password';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fixed scrypt-hashed credential used to equalise timing on the
|
|
141
|
+
* user-unknown path. {@link matchOperCred} always invokes exactly one
|
|
142
|
+
* verify — against this entry when no configured user matches, or against
|
|
143
|
+
* the matching cred when one does — so a remote attacker cannot
|
|
144
|
+
* distinguish "user exists, wrong password" from "user unknown" via
|
|
145
|
+
* wall-clock cost. Mirrors the {@link PASS_AUTH_DUMMY_NICK} pattern in
|
|
146
|
+
* `account-auth.ts`.
|
|
147
|
+
*/
|
|
148
|
+
export const OPER_PRECOMPUTED_DUMMY_HASHED: HashedAccountCredential = hashAccountCredential(
|
|
149
|
+
OPER_DUMMY_USER,
|
|
150
|
+
OPER_DUMMY_PASSWORD,
|
|
151
|
+
{
|
|
152
|
+
salt: OPER_FIXTURE_SALT,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The precomputed hashed {@link OperCredHashed} used by tests to exercise
|
|
158
|
+
* the hashed-verify arm of {@link matchOperCred} deterministically without
|
|
159
|
+
* paying scrypt cost in the test fixture setup. The salt is fixed (the
|
|
160
|
+
* {@link OPER_FIXTURE_SALT} vector) so the resulting `hash` is stable
|
|
161
|
+
* across runs and Node versions; the password is
|
|
162
|
+
* {@link OPER_HASHED_VALID_PASSWORD}.
|
|
163
|
+
*/
|
|
164
|
+
export const OPER_PRECOMPUTED_HASHED_CRED: OperCredHashed = (() => {
|
|
165
|
+
const entry = hashAccountCredential(OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD, {
|
|
166
|
+
salt: OPER_FIXTURE_SALT,
|
|
167
|
+
});
|
|
168
|
+
return { user: entry.account, salt: entry.salt, hash: entry.hash };
|
|
169
|
+
})();
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Type-narrowing predicate: true iff `cred` is the legacy plaintext shape.
|
|
173
|
+
*/
|
|
174
|
+
function isLegacyPlaintext(cred: OperCred): cred is OperCredPlaintext {
|
|
175
|
+
return (cred as OperCredPlaintext).password !== undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Timing-safe byte-wise string comparison. Returns false immediately on
|
|
180
|
+
* length mismatch (the length is itself public — it is observable in the
|
|
181
|
+
* user-name field on the wire — so leaking it does not aid the attacker).
|
|
182
|
+
* For equal-length inputs every byte is compared via `timingSafeEqual`
|
|
183
|
+
* with no short-circuit.
|
|
184
|
+
*/
|
|
185
|
+
function timingSafeStringEqual(a: string, b: string): boolean {
|
|
186
|
+
const aBuf = Buffer.from(a, 'utf8');
|
|
187
|
+
const bBuf = Buffer.from(b, 'utf8');
|
|
188
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
189
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Indirection over {@link verifyHashedPassword} so tests can swap in a
|
|
194
|
+
* stub to assert call counts (timing-equalisation test). Defaults to the
|
|
195
|
+
* real primitive; `null` restores the default.
|
|
196
|
+
*/
|
|
197
|
+
let verifyHashedPasswordFn: (password: string, entry: HashedAccountCredential) => boolean =
|
|
198
|
+
verifyHashedPassword;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Test seam: replaces the verify primitive with `fn` (or restores the
|
|
202
|
+
* default when `fn === null`). Exported for tests; production callers
|
|
203
|
+
* MUST NOT use.
|
|
204
|
+
*
|
|
205
|
+
* @internal
|
|
206
|
+
*/
|
|
207
|
+
export function setOperVerifyHashedPassword(
|
|
208
|
+
fn: ((password: string, entry: HashedAccountCredential) => boolean) | null,
|
|
209
|
+
): void {
|
|
210
|
+
verifyHashedPasswordFn = fn ?? verifyHashedPassword;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @internal test seam: clears any spy registered above. */
|
|
214
|
+
export function resetOperTimingSpy(): void {
|
|
215
|
+
verifyHashedPasswordFn = verifyHashedPassword;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Per-boot flag guarding the legacy-plaintext deprecation warning so the
|
|
220
|
+
* message is emitted exactly once per process (the configured cred list
|
|
221
|
+
* rarely changes within a single boot, and per-call spam would drown the
|
|
222
|
+
* signal).
|
|
223
|
+
*/
|
|
224
|
+
let legacyPlaintextWarnEmitted = false;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Default warner: `console.warn` on every platform the project ships
|
|
228
|
+
* (Node for Lambda / local-cli, `nodejs_compat` on Cloudflare Workers).
|
|
229
|
+
* `console.warn` is intentionally the sink rather than a structured
|
|
230
|
+
* logger because `matchOperCred` is a pure helper with no logger
|
|
231
|
+
* injection seam; adapters that want richer routing can wrap the boot
|
|
232
|
+
* config inspection separately.
|
|
233
|
+
*/
|
|
234
|
+
let operLegacyPlaintextWarner: (msg: string) => void = (msg) => {
|
|
235
|
+
console.warn(msg);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** Fixed deprecation text emitted once per boot when a legacy cred is seen. */
|
|
239
|
+
const LEGACY_PLAINTEXT_WARNING =
|
|
240
|
+
'Oper credentials configured in the deprecated {user,password} plaintext form. ' +
|
|
241
|
+
'Run tools/hash-oper-cred.ts to migrate to the scrypt-hashed {user,salt,hash} form ' +
|
|
242
|
+
'before the next release; the plaintext form will be removed.';
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @internal test seam: replaces the warner sink. Pass `null` to restore
|
|
246
|
+
* the default `console.warn` sink.
|
|
247
|
+
*/
|
|
248
|
+
export function setOperLegacyPlaintextWarner(fn: ((msg: string) => void) | null): void {
|
|
249
|
+
operLegacyPlaintextWarner =
|
|
250
|
+
fn ??
|
|
251
|
+
((msg) => {
|
|
252
|
+
console.warn(msg);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @internal test seam: clears the per-boot warning flag. */
|
|
257
|
+
export function resetOperLegacyPlaintextWarning(): void {
|
|
258
|
+
legacyPlaintextWarnEmitted = false;
|
|
259
|
+
}
|
|
260
|
+
|
|
59
261
|
/**
|
|
60
|
-
* Returns true iff a credential pair in `creds` matches `name` / `password
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
262
|
+
* Returns true iff a credential pair in `creds` matches `name` / `password`.
|
|
263
|
+
*
|
|
264
|
+
* Extracted as a pure helper so the match logic is testable in isolation
|
|
265
|
+
* and independent of connection state. Verification is timing-safe end to
|
|
266
|
+
* end:
|
|
267
|
+
* - The `user` field is compared via `timingSafeEqual` byte-wise (with
|
|
268
|
+
* a length precheck; the length is public on the wire).
|
|
269
|
+
* - For the at-rest hashed form, the password is verified via the shared
|
|
270
|
+
* {@link verifyHashedPassword} scrypt primitive (already
|
|
271
|
+
* `timingSafeEqual`-based).
|
|
272
|
+
* - For the deprecated legacy plaintext form, the password is verified
|
|
273
|
+
* via `timingSafeEqual` byte-wise as well.
|
|
274
|
+
* - When the user is unknown, a dummy scrypt verify runs against a
|
|
275
|
+
* fixed precomputed entry ({@link OPER_PRECOMPUTED_DUMMY_HASHED}) so
|
|
276
|
+
* the wall-clock cost is indistinguishable from the known-user path —
|
|
277
|
+
* a remote attacker cannot enumerate oper names via timing.
|
|
278
|
+
*
|
|
279
|
+
* The legacy plaintext form triggers a one-shot `warn` per boot so
|
|
280
|
+
* operators notice and rotate; the deprecation cycle removes the form
|
|
281
|
+
* entirely in a future release.
|
|
64
282
|
*/
|
|
65
283
|
export function matchOperCred(
|
|
66
284
|
creds: ReadonlyArray<OperCred>,
|
|
67
285
|
name: string,
|
|
68
286
|
password: string,
|
|
69
287
|
): boolean {
|
|
70
|
-
|
|
288
|
+
// Locate the cred whose user matches `name` (timing-safe on the user
|
|
289
|
+
// field). Iterate the WHOLE list — never short-circuit on a match — so
|
|
290
|
+
// the iteration count does not leak which entry matched.
|
|
291
|
+
let candidate: OperCred | undefined;
|
|
292
|
+
let sawLegacyPlaintext = false;
|
|
293
|
+
for (const c of creds) {
|
|
294
|
+
if (isLegacyPlaintext(c)) sawLegacyPlaintext = true;
|
|
295
|
+
if (timingSafeStringEqual(c.user, name)) {
|
|
296
|
+
candidate = c;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (sawLegacyPlaintext && !legacyPlaintextWarnEmitted) {
|
|
301
|
+
legacyPlaintextWarnEmitted = true;
|
|
302
|
+
operLegacyPlaintextWarner(LEGACY_PLAINTEXT_WARNING);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (candidate === undefined) {
|
|
306
|
+
// Unknown user: equalise timing with a dummy scrypt verify against a
|
|
307
|
+
// fixed precomputed entry. The result is discarded; the cost is the
|
|
308
|
+
// point.
|
|
309
|
+
verifyHashedPasswordFn(password, OPER_PRECOMPUTED_DUMMY_HASHED);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!isLegacyPlaintext(candidate)) {
|
|
314
|
+
// At-rest hashed form: delegate to the shared scrypt verify.
|
|
315
|
+
const entry: HashedAccountCredential = {
|
|
316
|
+
account: candidate.user,
|
|
317
|
+
algorithm: 'scrypt',
|
|
318
|
+
salt: candidate.salt,
|
|
319
|
+
hash: candidate.hash,
|
|
320
|
+
};
|
|
321
|
+
return verifyHashedPasswordFn(password, entry);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Legacy plaintext (deprecation cycle): timing-safe byte compare.
|
|
325
|
+
return timingSafeStringEqual(candidate.password, password);
|
|
71
326
|
}
|
|
72
327
|
|
|
73
328
|
/**
|
|
@@ -75,11 +330,27 @@ export function matchOperCred(
|
|
|
75
330
|
*
|
|
76
331
|
* Verifies the supplied pair against `serverConfig.operCreds` and, on
|
|
77
332
|
* success, grants the oper user mode. Emits the appropriate numeric reply
|
|
78
|
-
* in every branch. See the file header for the full decision table
|
|
333
|
+
* in every branch. See the file header for the full decision table
|
|
334
|
+
* (including the per-IP brute-force lockout).
|
|
79
335
|
*/
|
|
80
336
|
export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
81
337
|
state.lastSeen = ctx.clock.now();
|
|
82
338
|
|
|
339
|
+
// Registration gate (mirrors the SETNAME reducer): OPER is meaningless —
|
|
340
|
+
// and unattributable — before the welcome block. Runs first so a
|
|
341
|
+
// pre-registration caller never reaches the credential store, the
|
|
342
|
+
// lockout counters, or the oper-grant path.
|
|
343
|
+
if (state.registration !== 'registered') {
|
|
344
|
+
return {
|
|
345
|
+
state,
|
|
346
|
+
effects: [
|
|
347
|
+
Effect.send(ctx.connId, [
|
|
348
|
+
numericLine(ctx, Numerics.ERR_NOTREGISTERED, NOTREGISTERED_TRAILING_TEXT),
|
|
349
|
+
]),
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
83
354
|
const name = msg.params[0];
|
|
84
355
|
const password = msg.params[1];
|
|
85
356
|
|
|
@@ -95,6 +366,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
95
366
|
}
|
|
96
367
|
|
|
97
368
|
// Already an operator: graceful idempotent re-confirm, no re-auth needed.
|
|
369
|
+
// Runs BEFORE the lockout gate — an authenticated oper never loses 381
|
|
370
|
+
// because a (possibly different) connection from their host is throttled.
|
|
98
371
|
if (state.userModes.oper) {
|
|
99
372
|
return {
|
|
100
373
|
state,
|
|
@@ -106,6 +379,15 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
106
379
|
};
|
|
107
380
|
}
|
|
108
381
|
|
|
382
|
+
// Per-IP brute-force gate: once the source host has exhausted its failure
|
|
383
|
+
// budget, reject without consulting the credential store and notify every
|
|
384
|
+
// oper. Runs before the creds-configured check so the locked-out path
|
|
385
|
+
// performs zero credential work of any kind.
|
|
386
|
+
const lockout = operLockoutResult(state, ctx);
|
|
387
|
+
if (lockout !== undefined) {
|
|
388
|
+
return lockout;
|
|
389
|
+
}
|
|
390
|
+
|
|
109
391
|
const creds = ctx.serverConfig.operCreds;
|
|
110
392
|
if (creds === undefined || creds.length === 0) {
|
|
111
393
|
return {
|
|
@@ -120,6 +402,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
120
402
|
|
|
121
403
|
if (matchOperCred(creds, name, password)) {
|
|
122
404
|
state.userModes.oper = true;
|
|
405
|
+
// A legitimate oper's success never contributes to a later lockout.
|
|
406
|
+
resetOperFailures(state, ctx);
|
|
123
407
|
return {
|
|
124
408
|
state,
|
|
125
409
|
effects: [
|
|
@@ -131,6 +415,8 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
131
415
|
};
|
|
132
416
|
}
|
|
133
417
|
|
|
418
|
+
// Credential failure: count it against the source host's budget.
|
|
419
|
+
recordOperFailure(state, ctx);
|
|
134
420
|
return {
|
|
135
421
|
state,
|
|
136
422
|
effects: [
|
|
@@ -141,6 +427,73 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
141
427
|
};
|
|
142
428
|
};
|
|
143
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Returns the lockout rejection ({@link ReducerResult}) when the source
|
|
432
|
+
* host has exhausted its failed-OPER budget, or `undefined` when the
|
|
433
|
+
* credential path should proceed. No-ops (returns `undefined`) when no
|
|
434
|
+
* tracker is bound, the connection has no host, or the in-window failure
|
|
435
|
+
* count is still below the configured threshold.
|
|
436
|
+
*/
|
|
437
|
+
function operLockoutResult(
|
|
438
|
+
state: ConnectionState,
|
|
439
|
+
ctx: Ctx,
|
|
440
|
+
): ReducerResult<ConnectionState> | undefined {
|
|
441
|
+
const tracker = ctx.operFailures;
|
|
442
|
+
if (tracker === undefined) return undefined;
|
|
443
|
+
const key = state.host;
|
|
444
|
+
if (key === undefined) return undefined;
|
|
445
|
+
|
|
446
|
+
const { maxFailures, windowMs } = operLockoutThresholds(ctx);
|
|
447
|
+
if (tracker.recentFailures(key, windowMs) < maxFailures) return undefined;
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
state,
|
|
451
|
+
effects: [
|
|
452
|
+
Effect.send(ctx.connId, [
|
|
453
|
+
numericLine(ctx, Numerics.ERR_NOOPERHOST, NOOPERHOST_TRAILING_TEXT),
|
|
454
|
+
]),
|
|
455
|
+
Effect.broadcastOperNotice([operLockoutNoticeLine(ctx, key)]),
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolves the lockout thresholds from `serverConfig.oper`, falling back
|
|
462
|
+
* to the documented defaults when the config (or an individual field) is
|
|
463
|
+
* omitted.
|
|
464
|
+
*/
|
|
465
|
+
function operLockoutThresholds(ctx: Ctx): { maxFailures: number; windowMs: number } {
|
|
466
|
+
const cfg = ctx.serverConfig.oper;
|
|
467
|
+
const maxFailures = cfg?.maxFailures ?? DEFAULT_OPER_MAX_FAILURES;
|
|
468
|
+
const failureWindowSeconds = cfg?.failureWindowSeconds ?? DEFAULT_OPER_FAILURE_WINDOW_SECONDS;
|
|
469
|
+
return { maxFailures, windowMs: failureWindowSeconds * 1000 };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Builds the `:<server> NOTICE * :OPER lockout triggered for <host>` line
|
|
474
|
+
* fanned out to every oper. Target `*` (not a nick) because the notice is
|
|
475
|
+
* server-authored and addressed to the oper corps as a whole.
|
|
476
|
+
*/
|
|
477
|
+
function operLockoutNoticeLine(ctx: Ctx, host: string): RawLine {
|
|
478
|
+
return { text: `:${ctx.serverName} NOTICE * :${OPER_LOCKOUT_NOTICE_PREFIX} ${host}` };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Records a credential failure against the host's budget (no-op untracked). */
|
|
482
|
+
function recordOperFailure(state: ConnectionState, ctx: Ctx): void {
|
|
483
|
+
const tracker = ctx.operFailures;
|
|
484
|
+
const key = state.host;
|
|
485
|
+
if (tracker === undefined || key === undefined) return;
|
|
486
|
+
tracker.recordFailure(key);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Clears the host's failure budget after a successful authentication. */
|
|
490
|
+
function resetOperFailures(state: ConnectionState, ctx: Ctx): void {
|
|
491
|
+
const tracker = ctx.operFailures;
|
|
492
|
+
const key = state.host;
|
|
493
|
+
if (tracker === undefined || key === undefined) return;
|
|
494
|
+
tracker.reset(key);
|
|
495
|
+
}
|
|
496
|
+
|
|
144
497
|
/**
|
|
145
498
|
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
|
|
146
499
|
* falls back to `*` when the connection has not yet registered one, matching
|
|
@@ -8,26 +8,20 @@
|
|
|
8
8
|
* `ctx.connection.joinedChannels` (the connection's joined list) are
|
|
9
9
|
* expressed as effects / cross-authority mutations the actor layer persists.
|
|
10
10
|
*
|
|
11
|
-
* Multi-channel PART (`PART #a,#b :reason`) is split by the actor layer
|
|
12
|
-
*
|
|
11
|
+
* Multi-channel PART (`PART #a,#b :reason`) is split by the actor layer,
|
|
12
|
+
* which also enforces `maxTargetsPerCommand` (dropped channels get a
|
|
13
|
+
* `407 ERR_TOOMANYTARGETS`); this reducer handles one channel per invocation.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { Effect } from '../effects.js';
|
|
16
17
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
18
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
17
19
|
import { Numerics } from '../protocol/numerics.js';
|
|
18
20
|
import type { ChannelState } from '../state/channel.js';
|
|
19
21
|
import { hostmaskOf } from '../state/connection.js';
|
|
20
22
|
import type { ConnectionState } from '../state/connection.js';
|
|
21
23
|
import type { Ctx, Reducer } from '../types.js';
|
|
22
24
|
|
|
23
|
-
/** Channel-name validation mirrors JOIN: same grammar, same length cap. */
|
|
24
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
25
|
-
|
|
26
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
27
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
28
|
-
return CHANNEL_NAME_RE.test(name);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
/**
|
|
32
26
|
* Formats a single numeric error line addressed to the connection's current
|
|
33
27
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
@@ -25,16 +25,20 @@
|
|
|
25
25
|
|
|
26
26
|
import { Effect } from '../effects.js';
|
|
27
27
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
28
|
+
import { CHANNEL_NAME_RE } from '../protocol/channel-name.js';
|
|
28
29
|
import { Numerics } from '../protocol/numerics.js';
|
|
29
30
|
import type { ChannelState } from '../state/channel.js';
|
|
30
31
|
import { hostmaskOf } from '../state/connection.js';
|
|
31
32
|
import type { ConnectionState } from '../state/connection.js';
|
|
32
33
|
import type { Ctx, Reducer } from '../types.js';
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Returns true iff `name` looks like a channel target (vs. a nick target).
|
|
37
|
+
* Routing-only check against the shared channel-name grammar: it applies no
|
|
38
|
+
* byte-length cap, so an over-long `#name` still routes to the channel path
|
|
39
|
+
* (where the channel cannot exist) rather than silently becoming a nick
|
|
40
|
+
* message.
|
|
41
|
+
*/
|
|
38
42
|
function isChannelTarget(name: string): boolean {
|
|
39
43
|
return CHANNEL_NAME_RE.test(name);
|
|
40
44
|
}
|