serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- 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 +110 -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 +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -10,12 +10,14 @@ import type { Effect } from './effects.js';
|
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
11
|
import type {
|
|
12
12
|
AccountStore,
|
|
13
|
+
AwayStore,
|
|
13
14
|
Clock,
|
|
14
15
|
IdFactory,
|
|
15
16
|
MessageStore,
|
|
16
17
|
MotdProvider,
|
|
17
18
|
MtlsIdentityProvider,
|
|
18
19
|
NickHistoryStore,
|
|
20
|
+
ReadMarkerStore,
|
|
19
21
|
} from './ports.js';
|
|
20
22
|
import type { IrcMessage } from './protocol/messages.js';
|
|
21
23
|
import type { ConnectionState } from './state/connection.js';
|
|
@@ -33,6 +35,23 @@ export interface CloakingConfig {
|
|
|
33
35
|
readonly cloakedSuffix?: string | undefined;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* IRCv3 `sts` (Strict Transport Security) policy as seen by reducers.
|
|
40
|
+
* Mirrors the {@link StsConfigSchema} shape; the cap builder reads it to
|
|
41
|
+
* construct the `sts=…` advertisement per connection.
|
|
42
|
+
*
|
|
43
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
44
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
45
|
+
*/
|
|
46
|
+
export interface StsConfig {
|
|
47
|
+
/** Pin duration in seconds. */
|
|
48
|
+
readonly duration: number;
|
|
49
|
+
/** TLS listener port (plaintext advertisements only). */
|
|
50
|
+
readonly port: number;
|
|
51
|
+
/** Optional preload directive (plaintext advertisements only). */
|
|
52
|
+
readonly preload?: boolean | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
/**
|
|
37
56
|
* A single IRC operator credential. The {@link operReducer} consults the
|
|
38
57
|
* configured list to authenticate `OPER <name> <password>`. Both fields are
|
|
@@ -48,7 +67,7 @@ export interface OperCred {
|
|
|
48
67
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
49
68
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
50
69
|
*
|
|
51
|
-
* This is the minimum the Phase 1 reducers need; later
|
|
70
|
+
* This is the minimum the Phase 1 reducers need; later phases (SASL,
|
|
52
71
|
* MOTD provider, channel-prefix policy) extend it.
|
|
53
72
|
*/
|
|
54
73
|
export interface ServerConfig {
|
|
@@ -101,6 +120,13 @@ export interface ServerConfig {
|
|
|
101
120
|
nickLen: number;
|
|
102
121
|
channelLen: number;
|
|
103
122
|
topicLen: number;
|
|
123
|
+
/**
|
|
124
|
+
* Maximum realname (GECOS) length enforced by `SETNAME`. Optional because
|
|
125
|
+
* registration-time `USER` does not enforce a cap; the parsed config always
|
|
126
|
+
* supplies a value (schema default), but inline `ServerConfig` literals in
|
|
127
|
+
* tests may omit it — `SETNAME` then falls back to its built-in default.
|
|
128
|
+
*/
|
|
129
|
+
readonly realnameLen?: number | undefined;
|
|
104
130
|
quitMessage: string;
|
|
105
131
|
/**
|
|
106
132
|
* Per-connection message-rate flood control. `undefined` → the actor
|
|
@@ -120,6 +146,55 @@ export interface ServerConfig {
|
|
|
120
146
|
* `exactOptionalPropertyTypes: true`.
|
|
121
147
|
*/
|
|
122
148
|
chatHistoryPlaybackLimit?: number | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* IRCv3 `sts` (Strict Transport Security) policy. `undefined` disables
|
|
151
|
+
* STS advertisement; an object supplies the policy the cap builder
|
|
152
|
+
* serializes per connection (plaintext vs TLS).
|
|
153
|
+
*
|
|
154
|
+
* Typed `StsConfig | undefined` to mirror Zod's optional output shape
|
|
155
|
+
* under `exactOptionalPropertyTypes: true`.
|
|
156
|
+
*/
|
|
157
|
+
readonly sts?: StsConfig | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* IRCv3 `draft/multiline` byte budget. Advertised as
|
|
160
|
+
* `draft/multiline=<n>` in `CAP LS` and enforced on every finalized
|
|
161
|
+
* multi-line batch. Defaults to {@link DEFAULT_MULTILINE_MAX_BYTES}
|
|
162
|
+
* (4096) when omitted; the parsed config always supplies a value.
|
|
163
|
+
*
|
|
164
|
+
* Typed `number | undefined` to mirror Zod's optional output shape
|
|
165
|
+
* under `exactOptionalPropertyTypes: true` so inline `ServerConfig`
|
|
166
|
+
* literals in tests may omit it.
|
|
167
|
+
*/
|
|
168
|
+
readonly multilineMaxBytes?: number | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
|
|
171
|
+
* in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
|
|
172
|
+
* {@link DEFAULT_MONITOR_LIMIT} (the monitor cap's built-in ceiling).
|
|
173
|
+
*
|
|
174
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
175
|
+
* `exactOptionalPropertyTypes: true`.
|
|
176
|
+
*/
|
|
177
|
+
readonly monitorLimit?: number | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* Extended-ban prefix sigil advertised as `EXTBAN=<prefix>,…` in
|
|
180
|
+
* `005 RPL_ISUPPORT`. Omitted → defaults to `~` (the IRC-standard
|
|
181
|
+
* extban prefix).
|
|
182
|
+
*
|
|
183
|
+
* Typed `string | undefined` to mirror Zod's optional output shape under
|
|
184
|
+
* `exactOptionalPropertyTypes: true`.
|
|
185
|
+
*/
|
|
186
|
+
readonly extbanPrefix?: string | undefined;
|
|
187
|
+
/**
|
|
188
|
+
* Whether IRC services (NickServ/ChanServ) are wired up for this
|
|
189
|
+
* deployment. When true, `005 RPL_ISUPPORT` advertises `ACCOUNTEXTBAN=a`
|
|
190
|
+
* (account-name extended bans), which are only meaningful once nicks are
|
|
191
|
+
* registered. Omitted/false → the token is suppressed until services
|
|
192
|
+
* ship.
|
|
193
|
+
*
|
|
194
|
+
* Typed `boolean | undefined` to mirror Zod's optional output shape under
|
|
195
|
+
* `exactOptionalPropertyTypes: true`.
|
|
196
|
+
*/
|
|
197
|
+
readonly servicesEnabled?: boolean | undefined;
|
|
123
198
|
}
|
|
124
199
|
|
|
125
200
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -155,10 +230,43 @@ export interface Ctx {
|
|
|
155
230
|
* entries and the WHOWAS reducer replays them.
|
|
156
231
|
*/
|
|
157
232
|
readonly history?: NickHistoryStore;
|
|
233
|
+
/**
|
|
234
|
+
* Read-marker persistence source for IRCv3 `draft/read-marker` (absent
|
|
235
|
+
* when the deployment does not persist read markers). When present, a
|
|
236
|
+
* `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
|
|
237
|
+
* client updates the per-`(account, channel)` marker, and SASL identify
|
|
238
|
+
* seeds the connection's `lastReadMarkers` from the stored values so a
|
|
239
|
+
* reconnect restores the user's read position.
|
|
240
|
+
*/
|
|
241
|
+
readonly readMarkers?: ReadMarkerStore;
|
|
242
|
+
/**
|
|
243
|
+
* Away-status persistence source for IRCv3 `draft/pre-away` (absent
|
|
244
|
+
* when the deployment does not persist away reasons). When present, a
|
|
245
|
+
* set/clear of `AWAY` from an identified connection mirrors the change
|
|
246
|
+
* into the per-`account` record, and SASL identify replays the stored
|
|
247
|
+
* reason onto the connection (emitting `306 RPL_NOWAWAY`) so a
|
|
248
|
+
* reconnect restores the user's away state. Omitted (→ `ctx.away`
|
|
249
|
+
* undefined) preserves the session-only behaviour: the connection
|
|
250
|
+
* still tracks its own `AWAY` but the reason is not shared across
|
|
251
|
+
* connections or restored on reconnect.
|
|
252
|
+
*/
|
|
253
|
+
readonly away?: AwayStore;
|
|
158
254
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
159
255
|
readonly connection: ConnectionState;
|
|
160
256
|
/** Convenience: `connection.id`. */
|
|
161
257
|
readonly connId: string;
|
|
258
|
+
/**
|
|
259
|
+
* IRCv3 `labeled-response` label carried by the inbound message (the value
|
|
260
|
+
* of the `+label` client tag), or `undefined` when the request was
|
|
261
|
+
* unlabeled. Plumbed through ctx so every reducer's emitted effects are
|
|
262
|
+
* uniformly attributable to the labeled request; the actor layer wraps the
|
|
263
|
+
* requester's replies in one `BATCH +label labeled-response … -label` frame
|
|
264
|
+
* (or emits an empty batch when there is no reply).
|
|
265
|
+
*
|
|
266
|
+
* Typed `string | undefined` to mirror the optional-output shape under
|
|
267
|
+
* `exactOptionalPropertyTypes: true`.
|
|
268
|
+
*/
|
|
269
|
+
readonly label?: string | undefined;
|
|
162
270
|
}
|
|
163
271
|
|
|
164
272
|
/** Result of a single reducer invocation. */
|
|
@@ -183,7 +291,15 @@ export interface BuildCtxOptions {
|
|
|
183
291
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
184
292
|
messages?: MessageStore;
|
|
185
293
|
history?: NickHistoryStore;
|
|
294
|
+
readMarkers?: ReadMarkerStore;
|
|
295
|
+
away?: AwayStore;
|
|
186
296
|
connection: ConnectionState;
|
|
297
|
+
/**
|
|
298
|
+
* IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
|
|
299
|
+
* Omitted (→ `ctx.label` undefined) when the request was unlabeled. See
|
|
300
|
+
* {@link Ctx.label}.
|
|
301
|
+
*/
|
|
302
|
+
readonly label?: string | undefined;
|
|
187
303
|
}
|
|
188
304
|
|
|
189
305
|
/** Constructs a {@link Ctx} from the required parts. */
|
|
@@ -201,6 +317,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
201
317
|
...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
|
|
202
318
|
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
203
319
|
...(opts.history !== undefined ? { history: opts.history } : {}),
|
|
320
|
+
...(opts.readMarkers !== undefined ? { readMarkers: opts.readMarkers } : {}),
|
|
321
|
+
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
322
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
204
323
|
};
|
|
205
324
|
return ctx;
|
|
206
325
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket IRC-message framing and the IRCv3 510-byte message budget.
|
|
3
|
+
*
|
|
4
|
+
* The IRCv3 WebSocket spec
|
|
5
|
+
* (https://ircv3.net/specs/websockets.html) changes two things about how a
|
|
6
|
+
* WebSocket text/binary message maps to an IRC message, relative to the
|
|
7
|
+
* legacy framing a raw-TCP-tolerant adapter uses:
|
|
8
|
+
*
|
|
9
|
+
* 1. **One IRC message per WebSocket message.** A frame never carries
|
|
10
|
+
* several `\r\n`-joined messages, so framing MUST NOT split on an
|
|
11
|
+
* embedded line break. A single optional trailing CR-LF (or bare LF)
|
|
12
|
+
* is stripped and the remainder is treated as exactly one IRC line.
|
|
13
|
+
*
|
|
14
|
+
* 2. **No trailing CR-LF on the wire.** The 512-byte IRC line limit
|
|
15
|
+
* therefore applies to the message content only, giving a hard cap of
|
|
16
|
+
* {@link MAX_WS_MESSAGE_BYTES} (510) bytes per WebSocket message.
|
|
17
|
+
*
|
|
18
|
+
* The legacy framing (`WsFrameMode === 'legacy'`) preserves the original
|
|
19
|
+
* adapter behaviour: split the whole frame on `/\r?\n/` so older clients
|
|
20
|
+
* that concatenate messages into one frame keep working. That is the same
|
|
21
|
+
* `splitFrameLines` logic the transport layer shipped before subprotocol
|
|
22
|
+
* negotiation existed.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is pure and platform-agnostic so it runs unchanged on
|
|
25
|
+
* every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { WsSubprotocol } from './ws-subprotocol.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How a WebSocket message is framed into IRC lines.
|
|
32
|
+
*
|
|
33
|
+
* - `spec-text` — `text.ircv3.net`: one IRC message per frame, trailing
|
|
34
|
+
* CR-LF stripped, never split.
|
|
35
|
+
* - `spec-binary` — `binary.ircv3.net`: same framing as `spec-text`.
|
|
36
|
+
* - `legacy` — no IRCv3 subprotocol negotiated: split the frame on
|
|
37
|
+
* `/\r?\n/` (the original adapter behaviour).
|
|
38
|
+
*/
|
|
39
|
+
export type WsFrameMode = 'spec-text' | 'spec-binary' | 'legacy';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maximum number of bytes allowed in a single IRCv3 WebSocket message.
|
|
43
|
+
*
|
|
44
|
+
* The IRC line limit is 512 bytes *including* the trailing CR-LF. WebSocket
|
|
45
|
+
* messages omit the trailing CR-LF, so the content budget is `512 - 2 = 510`.
|
|
46
|
+
* Inbound messages exceeding this MUST be rejected.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_WS_MESSAGE_BYTES = 510;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Maps a negotiated IRCv3 subprotocol onto the matching {@link WsFrameMode}.
|
|
52
|
+
* `undefined` (no subprotocol) maps to the legacy-tolerant framing.
|
|
53
|
+
*/
|
|
54
|
+
export function wsFrameModeFor(subproto: WsSubprotocol | undefined): WsFrameMode {
|
|
55
|
+
if (subproto === 'text.ircv3.net') return 'spec-text';
|
|
56
|
+
if (subproto === 'binary.ircv3.net') return 'spec-binary';
|
|
57
|
+
return 'legacy';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strips a single trailing `\r\n` (or bare `\n`) from `s`. Leaves every
|
|
62
|
+
* other character — including leading or embedded terminators — untouched.
|
|
63
|
+
* Used by the spec-mode framing to yield one bare IRC line per frame.
|
|
64
|
+
*/
|
|
65
|
+
export function stripTrailingCrLf(s: string): string {
|
|
66
|
+
if (s.endsWith('\r\n')) return s.slice(0, -2);
|
|
67
|
+
if (s.endsWith('\n')) return s.slice(0, -1);
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Splits a WebSocket text frame into IRC lines using the legacy-tolerant
|
|
73
|
+
* contract: split the whole frame on `/\r?\n/`, returning every part
|
|
74
|
+
* (including a possible trailing empty string). The empty frame yields no
|
|
75
|
+
* lines.
|
|
76
|
+
*
|
|
77
|
+
* This is the exact behaviour the transport layer shipped before IRCv3
|
|
78
|
+
* subprotocol negotiation; spec-mode framing ({@link frameToLines}) does
|
|
79
|
+
* NOT use it.
|
|
80
|
+
*/
|
|
81
|
+
export function splitFrameLines(text: string): string[] {
|
|
82
|
+
if (text.length === 0) return [];
|
|
83
|
+
return text.split(/\r?\n/u);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Frames `frame` into IRC lines according to `mode`.
|
|
88
|
+
*
|
|
89
|
+
* - `spec-text` / `spec-binary`: one IRC message per frame. A single
|
|
90
|
+
* trailing CR-LF (or bare LF) is stripped and the remainder is returned
|
|
91
|
+
* as the sole element. The frame is never split on an embedded line
|
|
92
|
+
* break. An empty frame (or a frame that is only a terminator) yields
|
|
93
|
+
* no lines.
|
|
94
|
+
* - `legacy`: delegates to {@link splitFrameLines} (split on `/\r?\n/`).
|
|
95
|
+
*/
|
|
96
|
+
export function frameToLines(frame: string, mode: WsFrameMode): string[] {
|
|
97
|
+
if (mode === 'legacy') return splitFrameLines(frame);
|
|
98
|
+
const line = stripTrailingCrLf(frame);
|
|
99
|
+
if (line.length === 0) return [];
|
|
100
|
+
return [line];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
|
|
105
|
+
* message budget, `false` when it exceeds it and MUST be rejected by the
|
|
106
|
+
* inbound path. Measured in UTF-16 code units, consistent with how
|
|
107
|
+
* {@link enforceLineLimit} and the input parser cap line length across this
|
|
108
|
+
* package (every supported runtime exposes `.length`).
|
|
109
|
+
*/
|
|
110
|
+
export function isWithinWsByteBudget(frame: string): boolean {
|
|
111
|
+
return frame.length <= MAX_WS_MESSAGE_BYTES;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Replaces every unpaired UTF-16 surrogate in `str` with U+FFFD (the
|
|
116
|
+
* Unicode replacement character).
|
|
117
|
+
*
|
|
118
|
+
* `text.ircv3.net` frames are UTF-8 text. A lone surrogate (half of a
|
|
119
|
+
* surrogate pair) has no valid UTF-8 encoding, so per the Unicode
|
|
120
|
+
* recommendation it is substituted with U+FFFD before the frame is handed
|
|
121
|
+
* to the parser. Valid surrogate pairs (supplementary-plane code points)
|
|
122
|
+
* are preserved unchanged, as is all BMP text.
|
|
123
|
+
*/
|
|
124
|
+
export function sanitizeForTextMode(str: string): string {
|
|
125
|
+
// A lone high surrogate (0xD800–0xDBFF) NOT followed by a low surrogate,
|
|
126
|
+
// and a lone low surrogate (0xDC00–0xDFFF) NOT preceded by a high
|
|
127
|
+
// surrogate. Both are replaced with U+FFFD.
|
|
128
|
+
return str.replace(
|
|
129
|
+
/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF]))|(?:(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/gu,
|
|
130
|
+
'\uFFFD',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket subprotocol negotiation for IRC connections (IRCv3).
|
|
3
|
+
*
|
|
4
|
+
* The IRCv3 WebSocket specification defines two subprotocols a client may
|
|
5
|
+
* offer in its opening handshake via the `Sec-WebSocket-Protocol` header:
|
|
6
|
+
*
|
|
7
|
+
* - `text.ircv3.net` — UTF-8 text frames, one IRC message per WebSocket
|
|
8
|
+
* message, no trailing CR-LF.
|
|
9
|
+
* - `binary.ircv3.net` — binary frames, one IRC message per WebSocket
|
|
10
|
+
* message, no trailing CR-LF.
|
|
11
|
+
*
|
|
12
|
+
* The header is a comma-separated list offered in client-preference order.
|
|
13
|
+
* These pure helpers parse the header and select the single subprotocol the
|
|
14
|
+
* server should agree to (the first supported entry, honouring the client's
|
|
15
|
+
* preference). They are dependency-free so they run unchanged on every
|
|
16
|
+
* runtime (Node, Workers, Lambda).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The IRCv3 binary WebSocket subprotocol. */
|
|
20
|
+
export const WS_SUBPROTO_BINARY = 'binary.ircv3.net';
|
|
21
|
+
|
|
22
|
+
/** The IRCv3 text WebSocket subprotocol. */
|
|
23
|
+
export const WS_SUBPROTO_TEXT = 'text.ircv3.net';
|
|
24
|
+
|
|
25
|
+
/** A supported IRCv3 WebSocket subprotocol name. */
|
|
26
|
+
export type WsSubprotocol = typeof WS_SUBPROTO_TEXT | typeof WS_SUBPROTO_BINARY;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses the `Sec-WebSocket-Protocol` header value into an ordered list of
|
|
30
|
+
* offered protocols (client-preference order). Whitespace around each item is
|
|
31
|
+
* trimmed and empty entries (stray/leading/trailing commas) are dropped.
|
|
32
|
+
* Duplicate entries are preserved in their original order.
|
|
33
|
+
*
|
|
34
|
+
* `null`, `undefined`, or an empty string yield an empty array.
|
|
35
|
+
*
|
|
36
|
+
* @param header The raw `Sec-WebSocket-Protocol` header value, if present.
|
|
37
|
+
*/
|
|
38
|
+
export function parseSecWsProtocolOffers(header: string | null | undefined): string[] {
|
|
39
|
+
if (header === null || header === undefined || header === '') {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const offers: string[] = [];
|
|
43
|
+
for (const raw of header.split(',')) {
|
|
44
|
+
const item = raw.trim();
|
|
45
|
+
if (item !== '') {
|
|
46
|
+
offers.push(item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return offers;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Selects the supported subprotocol to agree to from a list of client offers
|
|
54
|
+
* (in client-preference order). Returns the first offer that matches a
|
|
55
|
+
* supported subprotocol, or `null` when none match.
|
|
56
|
+
*
|
|
57
|
+
* @param offers The ordered list of offered protocols.
|
|
58
|
+
*/
|
|
59
|
+
export function selectSubprotocol(offers: readonly string[]): WsSubprotocol | null {
|
|
60
|
+
for (const offer of offers) {
|
|
61
|
+
if (offer === WS_SUBPROTO_TEXT || offer === WS_SUBPROTO_BINARY) {
|
|
62
|
+
return offer as WsSubprotocol;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
@@ -211,6 +211,24 @@ describe('AdmissionStats — release', () => {
|
|
|
211
211
|
const stats = makeStats(clock);
|
|
212
212
|
expect(() => stats.release('unknown')).not.toThrow();
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
it('deletes the per-IP and per-user keys when the last admission is released', () => {
|
|
216
|
+
// Covers the `next <= 0` delete branch in `release`: a single recorded
|
|
217
|
+
// admission, when released, must clear the keys entirely rather than
|
|
218
|
+
// leaving a zero entry in the maps.
|
|
219
|
+
const clock = new FakeClock(1_000);
|
|
220
|
+
const stats = makeStats(clock);
|
|
221
|
+
const d = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
|
|
222
|
+
if (!d.ok) throw new Error('precondition: d should admit');
|
|
223
|
+
stats.recordAdmission('5.6.7.8', 'carol', d.recordId);
|
|
224
|
+
|
|
225
|
+
stats.release(d.recordId);
|
|
226
|
+
|
|
227
|
+
// A fresh admission from the same IP/user must be allowed (counts are
|
|
228
|
+
// back to zero because the keys were deleted, not just decremented).
|
|
229
|
+
const after = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
|
|
230
|
+
expect(after.ok).toBe(true);
|
|
231
|
+
});
|
|
214
232
|
});
|
|
215
233
|
|
|
216
234
|
describe('AdmissionStats — determinism', () => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type AwayStore, InMemoryAwayStore } from '../src/ports';
|
|
3
|
+
|
|
4
|
+
describe('InMemoryAwayStore — port conformance', () => {
|
|
5
|
+
it('implements the AwayStore port', () => {
|
|
6
|
+
const store: AwayStore = new InMemoryAwayStore();
|
|
7
|
+
expect(store).toBeDefined();
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('InMemoryAwayStore — get / set / clear', () => {
|
|
12
|
+
it('returns undefined for an unknown account', () => {
|
|
13
|
+
const store = new InMemoryAwayStore();
|
|
14
|
+
expect(store.get('alice')).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('stores and returns the away reason for a known account', () => {
|
|
18
|
+
const store = new InMemoryAwayStore();
|
|
19
|
+
store.set('alice', 'brb');
|
|
20
|
+
expect(store.get('alice')).toBe('brb');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('overwrites the reason on a subsequent set for the same account', () => {
|
|
24
|
+
const store = new InMemoryAwayStore();
|
|
25
|
+
store.set('alice', 'brb');
|
|
26
|
+
store.set('alice', 'lunch');
|
|
27
|
+
expect(store.get('alice')).toBe('lunch');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keys accounts case-insensitively (rfc1459 fold)', () => {
|
|
31
|
+
const store = new InMemoryAwayStore();
|
|
32
|
+
store.set('Alice', 'brb');
|
|
33
|
+
expect(store.get('alice')).toBe('brb');
|
|
34
|
+
expect(store.get('ALICE')).toBe('brb');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps reasons for distinct accounts separate', () => {
|
|
38
|
+
const store = new InMemoryAwayStore();
|
|
39
|
+
store.set('alice', 'brb');
|
|
40
|
+
store.set('bob', 'afk');
|
|
41
|
+
expect(store.get('alice')).toBe('brb');
|
|
42
|
+
expect(store.get('bob')).toBe('afk');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clears the reason for an account on clear', () => {
|
|
46
|
+
const store = new InMemoryAwayStore();
|
|
47
|
+
store.set('alice', 'brb');
|
|
48
|
+
store.clear('alice');
|
|
49
|
+
expect(store.get('alice')).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('is a no-op to clear an account that has no stored reason', () => {
|
|
53
|
+
const store = new InMemoryAwayStore();
|
|
54
|
+
store.clear('alice');
|
|
55
|
+
expect(store.get('alice')).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('clears only the named account, leaving others intact', () => {
|
|
59
|
+
const store = new InMemoryAwayStore();
|
|
60
|
+
store.set('alice', 'brb');
|
|
61
|
+
store.set('bob', 'afk');
|
|
62
|
+
store.clear('alice');
|
|
63
|
+
expect(store.get('alice')).toBeUndefined();
|
|
64
|
+
expect(store.get('bob')).toBe('afk');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('folds the account name case-insensitively on clear', () => {
|
|
68
|
+
const store = new InMemoryAwayStore();
|
|
69
|
+
store.set('Alice', 'brb');
|
|
70
|
+
store.clear('ALICE');
|
|
71
|
+
expect(store.get('alice')).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -14,23 +14,44 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
14
14
|
const names = SUPPORTED_CAPABILITIES.map((c) => c.name).sort();
|
|
15
15
|
expect(names).toEqual(
|
|
16
16
|
[
|
|
17
|
+
'account-notify',
|
|
17
18
|
'account-tag',
|
|
18
19
|
'away-notify',
|
|
19
20
|
'batch',
|
|
20
21
|
'chghost',
|
|
21
22
|
'draft/chathistory',
|
|
23
|
+
'draft/multiline',
|
|
24
|
+
'draft/pre-away',
|
|
25
|
+
'draft/read-marker',
|
|
26
|
+
'draft/typing',
|
|
22
27
|
'echo-message',
|
|
23
28
|
'extended-join',
|
|
24
29
|
'invite-notify',
|
|
30
|
+
'labeled-response',
|
|
25
31
|
'message-tags',
|
|
32
|
+
'msgid',
|
|
33
|
+
'monitor',
|
|
26
34
|
'multi-prefix',
|
|
27
35
|
'safelist',
|
|
28
36
|
'sasl',
|
|
29
37
|
'server-time',
|
|
38
|
+
'standard-replies',
|
|
30
39
|
].sort(),
|
|
31
40
|
);
|
|
32
41
|
});
|
|
33
42
|
|
|
43
|
+
it('advertises draft/typing as a bare name (no value parameter)', () => {
|
|
44
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/typing');
|
|
45
|
+
expect(cap).toBeDefined();
|
|
46
|
+
expect(cap?.value).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('advertises the standard-replies cap as a bare name (no value parameter)', () => {
|
|
50
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'standard-replies');
|
|
51
|
+
expect(cap).toBeDefined();
|
|
52
|
+
expect(cap?.value).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
34
55
|
it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
|
|
35
56
|
const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
|
|
36
57
|
expect(sasl).toBeDefined();
|
|
@@ -38,9 +59,11 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
38
59
|
});
|
|
39
60
|
|
|
40
61
|
it('does not attach a value to caps that have no parameter', () => {
|
|
41
|
-
// sasl
|
|
62
|
+
// sasl and draft/multiline are the caps with a value parameter.
|
|
63
|
+
const withValue = SUPPORTED_CAPABILITIES.filter((c) => c.value !== undefined);
|
|
64
|
+
expect(withValue.map((c) => c.name).sort()).toEqual(['draft/multiline', 'sasl']);
|
|
42
65
|
const noValue = SUPPORTED_CAPABILITIES.filter((c) => c.value === undefined);
|
|
43
|
-
expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length -
|
|
66
|
+
expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length - withValue.length);
|
|
44
67
|
});
|
|
45
68
|
|
|
46
69
|
it('advertises safelist as a bare name (no value parameter)', () => {
|
|
@@ -134,6 +157,100 @@ describe('draft/chathistory advertisement', () => {
|
|
|
134
157
|
});
|
|
135
158
|
});
|
|
136
159
|
|
|
160
|
+
describe('msgid advertisement', () => {
|
|
161
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
162
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'msgid');
|
|
163
|
+
expect(cap).toBeDefined();
|
|
164
|
+
expect(cap?.value).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
168
|
+
expect(getLsString()).toContain('msgid');
|
|
169
|
+
expect(getLsString()).not.toContain('msgid=');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
173
|
+
expect(SUPPORTED_CAP_NAMES.has('msgid')).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('draft/typing advertisement', () => {
|
|
178
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
179
|
+
expect(getLsString()).toContain('draft/typing');
|
|
180
|
+
expect(getLsString()).not.toContain('draft/typing=');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
184
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/typing')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('is recognized by isSupportedCap', () => {
|
|
188
|
+
expect(isSupportedCap('draft/typing')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('draft/read-marker advertisement', () => {
|
|
193
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
194
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/read-marker');
|
|
195
|
+
expect(cap).toBeDefined();
|
|
196
|
+
expect(cap?.value).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
200
|
+
expect(getLsString()).toContain('draft/read-marker');
|
|
201
|
+
expect(getLsString()).not.toContain('draft/read-marker=');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
205
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/read-marker')).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('is recognized by isSupportedCap', () => {
|
|
209
|
+
expect(isSupportedCap('draft/read-marker')).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('draft/pre-away advertisement', () => {
|
|
214
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
215
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/pre-away');
|
|
216
|
+
expect(cap).toBeDefined();
|
|
217
|
+
expect(cap?.value).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
221
|
+
expect(getLsString()).toContain('draft/pre-away');
|
|
222
|
+
expect(getLsString()).not.toContain('draft/pre-away=');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
226
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/pre-away')).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('is recognized by isSupportedCap', () => {
|
|
230
|
+
expect(isSupportedCap('draft/pre-away')).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('draft/multiline advertisement', () => {
|
|
235
|
+
it('is advertised with the default byte budget value', () => {
|
|
236
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/multiline');
|
|
237
|
+
expect(cap).toBeDefined();
|
|
238
|
+
expect(cap?.value).toBe('4096');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('appears in CAP LS output as the name=4096 token', () => {
|
|
242
|
+
expect(getLsString()).toContain('draft/multiline=4096');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
246
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/multiline')).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('is recognized by isSupportedCap', () => {
|
|
250
|
+
expect(isSupportedCap('draft/multiline')).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
137
254
|
describe('splitCapsForWire', () => {
|
|
138
255
|
it('returns a single chunk when everything fits', () => {
|
|
139
256
|
const tokens = ['a', 'b', 'c'];
|