serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam: turns raw transport input into complete IRC lines.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §9 (ADR-009) — the pure reducer core + `ConnectionActor` are shared
|
|
5
|
+
* across both the WebSocket (`wss`) and the raw TCP+TLS (`irc+tls :6697`)
|
|
6
|
+
* transports. The only transport-specific concern is line framing:
|
|
7
|
+
* - A WebSocket text frame is a complete unit (one or N `\r\n`-joined
|
|
8
|
+
* messages), so a trailing unterminated line is still emitted.
|
|
9
|
+
* - A raw TCP byte stream arrives in arbitrary chunks, so a partial line
|
|
10
|
+
* at a chunk boundary must be buffered until its `\r\n` terminator
|
|
11
|
+
* arrives.
|
|
12
|
+
*
|
|
13
|
+
* Both transports emit complete lines (no terminators); the actor skips
|
|
14
|
+
* empty lines and parses each non-empty one through the shared
|
|
15
|
+
* parser/reducer/dispatch pipeline.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The line-framing contract a {@link ConnectionActor} consumes. `feed`
|
|
20
|
+
* returns the complete IRC lines terminated within `chunk`; a stateful
|
|
21
|
+
* transport buffers any incomplete trailing line for the next call.
|
|
22
|
+
*/
|
|
23
|
+
export interface Transport {
|
|
24
|
+
feed(chunk: string): string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
|
|
29
|
+
* and a missing trailing terminator (a WS frame is a complete unit). Returns
|
|
30
|
+
* each line bare (no CRLF). This is the legacy-tolerant WS contract.
|
|
31
|
+
*/
|
|
32
|
+
export function splitFrameLines(text: string): string[] {
|
|
33
|
+
if (text.length === 0) return [];
|
|
34
|
+
return text.split(/\r?\n/u);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stateless WS-frame transport: one {@link feed} call per inbound text frame.
|
|
39
|
+
* A frame may carry one message or N `\r\n`-joined messages; a trailing
|
|
40
|
+
* unterminated line is emitted because the frame itself is the unit of
|
|
41
|
+
* completeness. This is the default transport for the WebSocket adapters.
|
|
42
|
+
*/
|
|
43
|
+
export class WsTextFrameTransport implements Transport {
|
|
44
|
+
feed(chunk: string): string[] {
|
|
45
|
+
return splitFrameLines(chunk);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stateful TCP byte-stream transport: reassembles `\r\n`- (or bare `\n`-)
|
|
51
|
+
* terminated lines across arbitrary chunks, buffering any partial trailing
|
|
52
|
+
* line until its terminator arrives. Used by the raw TCP+TLS adapter path
|
|
53
|
+
* (PLAN §9). One instance owns one connection's buffer for its lifetime.
|
|
54
|
+
*
|
|
55
|
+
* For long-lived TCP connections fronted by request/response compute (AWS
|
|
56
|
+
* NLB + Lambda) each client chunk arrives in a fresh Lambda
|
|
57
|
+
* invocation. The partial-line buffer must persist across those
|
|
58
|
+
* invocations. {@link getBuffer} / {@link restore} snapshot the in-flight
|
|
59
|
+
* bytes so the adapter can round-trip them through its data store
|
|
60
|
+
* (DynamoDB) between chunks.
|
|
61
|
+
*/
|
|
62
|
+
export class TcpByteStreamTransport implements Transport {
|
|
63
|
+
private buffer = '';
|
|
64
|
+
|
|
65
|
+
feed(chunk: string): string[] {
|
|
66
|
+
if (chunk.length === 0) return [];
|
|
67
|
+
this.buffer += chunk;
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
let start = 0;
|
|
70
|
+
for (let i = 0; i < this.buffer.length; i++) {
|
|
71
|
+
if (this.buffer[i] === '\n') {
|
|
72
|
+
// A `\n` terminates a line. Strip a preceding `\r` so both `\r\n`
|
|
73
|
+
// and bare `\n` yield the same bare line.
|
|
74
|
+
const lineEnd = i > 0 && this.buffer[i - 1] === '\r' ? i - 1 : i;
|
|
75
|
+
lines.push(this.buffer.slice(start, lineEnd));
|
|
76
|
+
start = i + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.buffer = this.buffer.slice(start);
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns the partial-line bytes currently buffered (not yet terminated).
|
|
85
|
+
* Empty when every fed byte has been emitted as a complete line. The
|
|
86
|
+
* adapter persists this between compute invocations so a chunk split
|
|
87
|
+
* across two requests is reassembled correctly.
|
|
88
|
+
*/
|
|
89
|
+
getBuffer(): string {
|
|
90
|
+
return this.buffer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Replaces the internal buffer with `buffer`. Call before {@link feed}
|
|
95
|
+
* when restoring from a persisted snapshot. An empty (or omitted)
|
|
96
|
+
* `buffer` resets the transport to a fresh state.
|
|
97
|
+
*/
|
|
98
|
+
restore(buffer: string): void {
|
|
99
|
+
this.buffer = buffer;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -12,10 +12,13 @@ import {
|
|
|
12
12
|
FakeClock,
|
|
13
13
|
type FloodControlConfig,
|
|
14
14
|
InMemoryMessageStore,
|
|
15
|
+
InMemoryNickHistoryStore,
|
|
15
16
|
LogLevel,
|
|
16
17
|
type Logger,
|
|
17
18
|
type MessageStore,
|
|
19
|
+
type MtlsIdentityProvider,
|
|
18
20
|
type Nick,
|
|
21
|
+
type NickHistoryStore,
|
|
19
22
|
Numerics,
|
|
20
23
|
type RawLine,
|
|
21
24
|
type SaslPayload,
|
|
@@ -34,6 +37,8 @@ import {
|
|
|
34
37
|
import { describe, expect, it } from 'vitest';
|
|
35
38
|
import { ConnectionActor } from '../src/actor';
|
|
36
39
|
import type { IrcRuntime } from '../src/runtime';
|
|
40
|
+
import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
|
|
41
|
+
import type { Transport } from '../src/transport';
|
|
37
42
|
|
|
38
43
|
// ---------------------------------------------------------------------------
|
|
39
44
|
// Fakes
|
|
@@ -161,7 +166,10 @@ function makeActor(opts: {
|
|
|
161
166
|
traceId?: string;
|
|
162
167
|
messages?: MessageStore;
|
|
163
168
|
accounts?: AccountStore;
|
|
169
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
170
|
+
history?: NickHistoryStore;
|
|
164
171
|
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
172
|
+
transport?: Transport;
|
|
165
173
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
166
174
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
167
175
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -215,6 +223,15 @@ function makeActor(opts: {
|
|
|
215
223
|
if (opts.accounts !== undefined) {
|
|
216
224
|
finalOpts.accounts = opts.accounts;
|
|
217
225
|
}
|
|
226
|
+
if (opts.mtlsIdentity !== undefined) {
|
|
227
|
+
finalOpts.mtlsIdentity = opts.mtlsIdentity;
|
|
228
|
+
}
|
|
229
|
+
if (opts.history !== undefined) {
|
|
230
|
+
finalOpts.history = opts.history;
|
|
231
|
+
}
|
|
232
|
+
if (opts.transport !== undefined) {
|
|
233
|
+
finalOpts.transport = opts.transport;
|
|
234
|
+
}
|
|
218
235
|
const actor = new ConnectionActor(finalOpts);
|
|
219
236
|
return { actor, runtime, conn };
|
|
220
237
|
}
|
|
@@ -278,6 +295,112 @@ describe('ConnectionActor — framing', () => {
|
|
|
278
295
|
});
|
|
279
296
|
});
|
|
280
297
|
|
|
298
|
+
describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
299
|
+
it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
|
|
300
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
301
|
+
await runtime.applyChannelDelta('#foo', {
|
|
302
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
303
|
+
});
|
|
304
|
+
runtime.calls.length = 0;
|
|
305
|
+
|
|
306
|
+
// Split the single message mid-payload across two chunks. The partial
|
|
307
|
+
// line is buffered across the first chunk; only the second completes it.
|
|
308
|
+
await actor.receive('PRIVMSG #foo :hel');
|
|
309
|
+
expect(runtime.calls).toHaveLength(0);
|
|
310
|
+
await actor.receive('lo\r\n');
|
|
311
|
+
|
|
312
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
313
|
+
expect(broadcasts).toHaveLength(1);
|
|
314
|
+
expect(broadcasts[0]?.args[0]).toBe('#foo');
|
|
315
|
+
const lines = broadcasts[0]?.args[1] as RawLine[];
|
|
316
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('one message per WS text frame and one per CRLF over TCP produce identical effects', async () => {
|
|
320
|
+
// Same bytes, same connection setup, two transports. The WS actor gets
|
|
321
|
+
// the whole frame in one receiveTextFrame; the TCP actor gets the same
|
|
322
|
+
// bytes split across arbitrary chunks via receive().
|
|
323
|
+
const wsSetup = makeActor({});
|
|
324
|
+
const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
|
|
325
|
+
for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
|
|
326
|
+
await rt.applyChannelDelta('#foo', {
|
|
327
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
wsSetup.runtime.calls.length = 0;
|
|
331
|
+
tcpSetup.runtime.calls.length = 0;
|
|
332
|
+
|
|
333
|
+
const stream = 'PRIVMSG #foo :hello\r\n';
|
|
334
|
+
await wsSetup.actor.receiveTextFrame(stream);
|
|
335
|
+
// Feed the TCP actor the same bytes in 4-byte chunks.
|
|
336
|
+
for (let i = 0; i < stream.length; i += 4) {
|
|
337
|
+
await tcpSetup.actor.receive(stream.slice(i, i + 4));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const wsBroadcast = wsSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
341
|
+
const tcpBroadcast = tcpSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
342
|
+
expect(tcpBroadcast).toHaveLength(wsBroadcast.length);
|
|
343
|
+
expect((tcpBroadcast[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
344
|
+
(wsBroadcast[0]?.args[1] as RawLine[])[0]?.text,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
|
|
349
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
350
|
+
await runtime.applyChannelDelta('#foo', {
|
|
351
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
352
|
+
});
|
|
353
|
+
runtime.calls.length = 0;
|
|
354
|
+
|
|
355
|
+
// Three messages, each fed byte-by-byte. Every complete line is emitted
|
|
356
|
+
// exactly once; nothing is lost or duplicated at the chunk boundaries.
|
|
357
|
+
const stream = 'PING :a\r\nPRIVMSG #foo :b\r\nPING :c\r\n';
|
|
358
|
+
for (const ch of stream) {
|
|
359
|
+
await actor.receive(ch);
|
|
360
|
+
}
|
|
361
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
362
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
363
|
+
expect(sends).toHaveLength(2); // PONG :a, PONG :c
|
|
364
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
365
|
+
expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
366
|
+
expect(broadcasts).toHaveLength(1); // PRIVMSG #foo :b
|
|
367
|
+
expect((broadcasts[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
368
|
+
':alice!alice@example.com PRIVMSG #foo :b',
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('uses the default WS-frame transport when none is injected (backward compat)', () => {
|
|
373
|
+
// No `transport` option → the actor still frames WS text frames exactly
|
|
374
|
+
// as before. Confirms the default is the stateless WS transport.
|
|
375
|
+
const { actor, runtime } = makeActor({});
|
|
376
|
+
// A trailing unterminated line is still emitted by the WS transport
|
|
377
|
+
// (frame = complete unit), so this produces one PONG with no terminator.
|
|
378
|
+
return actor.receiveTextFrame('PING :tok').then(() => {
|
|
379
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
380
|
+
expect(sends).toHaveLength(1);
|
|
381
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :tok');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('a TCP actor does NOT emit a trailing unterminated line until the terminator arrives', async () => {
|
|
386
|
+
const tcpActor = makeActor({ transport: new TcpByteStreamTransport() });
|
|
387
|
+
const wsActor = makeActor({ transport: new WsTextFrameTransport() });
|
|
388
|
+
tcpActor.runtime.calls.length = 0;
|
|
389
|
+
wsActor.runtime.calls.length = 0;
|
|
390
|
+
|
|
391
|
+
// WS treats the frame as complete → emits a PONG. TCP buffers the
|
|
392
|
+
// unterminated line → emits nothing yet. This is the defining
|
|
393
|
+
// behavioural difference of stateful byte-stream framing.
|
|
394
|
+
await wsActor.actor.receiveTextFrame('PING :unterminated');
|
|
395
|
+
await tcpActor.actor.receive('PING :unterminated');
|
|
396
|
+
expect(wsActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
397
|
+
expect(tcpActor.runtime.calls).toHaveLength(0);
|
|
398
|
+
// …until the terminator completes the line.
|
|
399
|
+
await tcpActor.actor.receive('\r\n');
|
|
400
|
+
expect(tcpActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
281
404
|
describe('ConnectionActor — malformed input', () => {
|
|
282
405
|
it('emits 421 for an unparseable line and continues processing the rest', async () => {
|
|
283
406
|
const { actor, runtime } = makeActor({});
|
|
@@ -512,9 +635,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
512
635
|
|
|
513
636
|
// Cap-gated broadcast dispatches per-recipient via send (only to
|
|
514
637
|
// message-tags-enabled members, excluding the sender).
|
|
515
|
-
const sends = runtime.calls.filter(
|
|
516
|
-
(c) => c.method === 'send' && c.args[0] === 'c2',
|
|
517
|
-
);
|
|
638
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
518
639
|
expect(sends).toHaveLength(1);
|
|
519
640
|
const lines = sends[0]?.args[1] as RawLine[];
|
|
520
641
|
expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
|
|
@@ -664,6 +785,76 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
664
785
|
}
|
|
665
786
|
});
|
|
666
787
|
|
|
788
|
+
it('routes WHOWAS of a quit nick through whowasReducer (314 + 369, never 421)', async () => {
|
|
789
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
790
|
+
history.record({
|
|
791
|
+
nick: 'bob',
|
|
792
|
+
connId: 'c2',
|
|
793
|
+
username: 'bob',
|
|
794
|
+
hostname: 'example.com',
|
|
795
|
+
realname: 'Bob',
|
|
796
|
+
signoffTime: 500,
|
|
797
|
+
});
|
|
798
|
+
const { actor, runtime } = makeActor({ history });
|
|
799
|
+
runtime.calls.length = 0;
|
|
800
|
+
|
|
801
|
+
await actor.receiveTextFrame('WHOWAS bob\r\n');
|
|
802
|
+
|
|
803
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
804
|
+
expect(sends.length).toBeGreaterThanOrEqual(2);
|
|
805
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
806
|
+
expect(firstBatch[0]?.text).toContain(' 314 ');
|
|
807
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
808
|
+
const endBatch = sends[sends.length - 1]?.args[1] as RawLine[];
|
|
809
|
+
expect(endBatch[0]?.text).toContain(' 369 ');
|
|
810
|
+
for (const s of sends) {
|
|
811
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
812
|
+
expect(line.text).not.toContain(' 421 ');
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('routes WHOWAS of an unknown nick to 406 then 369 (never 421)', async () => {
|
|
818
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
819
|
+
const { actor, runtime } = makeActor({ history });
|
|
820
|
+
runtime.calls.length = 0;
|
|
821
|
+
|
|
822
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
823
|
+
|
|
824
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
825
|
+
expect(sends).toHaveLength(2);
|
|
826
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
827
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
828
|
+
expect(notFound[0]?.text).toContain(' ghost ');
|
|
829
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
830
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('still emits 406 + 369 for WHOWAS when no history store is bound (no crash)', async () => {
|
|
834
|
+
const { actor, runtime } = makeActor({});
|
|
835
|
+
runtime.calls.length = 0;
|
|
836
|
+
|
|
837
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
838
|
+
|
|
839
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
840
|
+
expect(sends).toHaveLength(2);
|
|
841
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
842
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
843
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
844
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('records a nick into history on QUIT when a store is bound', async () => {
|
|
848
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
849
|
+
const { actor } = makeActor({ history });
|
|
850
|
+
|
|
851
|
+
await actor.receiveTextFrame('QUIT\r\n');
|
|
852
|
+
|
|
853
|
+
const entries = history.query('alice', 10);
|
|
854
|
+
expect(entries).toHaveLength(1);
|
|
855
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
856
|
+
});
|
|
857
|
+
|
|
667
858
|
it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
|
|
668
859
|
// Pre-registration: client has opened CAP negotiation and requested sasl.
|
|
669
860
|
// AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
|
|
@@ -1489,3 +1680,209 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
1489
1680
|
expect(conn.account).toBeUndefined();
|
|
1490
1681
|
});
|
|
1491
1682
|
});
|
|
1683
|
+
|
|
1684
|
+
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
1685
|
+
/** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
|
|
1686
|
+
class FakeAccountStore implements AccountStore {
|
|
1687
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1688
|
+
constructor(private readonly result: SaslResult) {}
|
|
1689
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1690
|
+
this.calls.push({ mech, payload });
|
|
1691
|
+
return this.result;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
|
|
1696
|
+
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
1697
|
+
constructor(private readonly identity: string | undefined) {}
|
|
1698
|
+
getIdentity(connId: string): string | undefined {
|
|
1699
|
+
return connId === 'c1' ? this.identity : undefined;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function saslReadyConn(): Partial<ConnectionState> {
|
|
1704
|
+
return {
|
|
1705
|
+
registration: 'pre-registration',
|
|
1706
|
+
capNegotiating: true,
|
|
1707
|
+
caps: new Set<string>(['sasl']),
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
1712
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1713
|
+
const mtlsIdentity = new FakeMtlsProvider('CN=alice');
|
|
1714
|
+
const { actor, runtime, conn } = makeActor({
|
|
1715
|
+
conn: saslReadyConn(),
|
|
1716
|
+
accounts,
|
|
1717
|
+
mtlsIdentity,
|
|
1718
|
+
});
|
|
1719
|
+
conn.nick = 'alice';
|
|
1720
|
+
runtime.calls.length = 0;
|
|
1721
|
+
|
|
1722
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
1723
|
+
await actor.receiveTextFrame('AUTHENTICATE +\r\n');
|
|
1724
|
+
|
|
1725
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1726
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1727
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
1728
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
1729
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1730
|
+
expect(accounts.calls).toEqual([
|
|
1731
|
+
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
1732
|
+
]);
|
|
1733
|
+
expect(conn.account).toBe('alice');
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
|
|
1737
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1738
|
+
const { actor, runtime, conn } = makeActor({
|
|
1739
|
+
conn: saslReadyConn(),
|
|
1740
|
+
accounts,
|
|
1741
|
+
// No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
|
|
1742
|
+
});
|
|
1743
|
+
conn.nick = 'alice';
|
|
1744
|
+
runtime.calls.length = 0;
|
|
1745
|
+
|
|
1746
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
1747
|
+
|
|
1748
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1749
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1750
|
+
expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
|
|
1751
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1752
|
+
expect(conn.account).toBeUndefined();
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
|
|
1757
|
+
it('routes VERSION through versionReducer (351, never 421)', async () => {
|
|
1758
|
+
const { actor, runtime } = makeActor({});
|
|
1759
|
+
runtime.calls.length = 0;
|
|
1760
|
+
|
|
1761
|
+
await actor.receiveTextFrame('VERSION\r\n');
|
|
1762
|
+
|
|
1763
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1764
|
+
expect(sends).toHaveLength(1);
|
|
1765
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1766
|
+
expect(lines[0]?.text).toContain(' 351 ');
|
|
1767
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
it('routes TIME through timeReducer (391, never 421)', async () => {
|
|
1771
|
+
const { actor, runtime } = makeActor({});
|
|
1772
|
+
runtime.calls.length = 0;
|
|
1773
|
+
|
|
1774
|
+
await actor.receiveTextFrame('TIME\r\n');
|
|
1775
|
+
|
|
1776
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1777
|
+
expect(sends).toHaveLength(1);
|
|
1778
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1779
|
+
expect(lines[0]?.text).toContain(' 391 ');
|
|
1780
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('routes ADMIN through adminReducer (256, never 421)', async () => {
|
|
1784
|
+
const { actor, runtime } = makeActor({});
|
|
1785
|
+
runtime.calls.length = 0;
|
|
1786
|
+
|
|
1787
|
+
await actor.receiveTextFrame('ADMIN\r\n');
|
|
1788
|
+
|
|
1789
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1790
|
+
expect(sends).toHaveLength(1);
|
|
1791
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1792
|
+
expect(lines[0]?.text).toContain(' 256 ');
|
|
1793
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
it('routes INFO through infoReducer (371 + 374, never 421)', async () => {
|
|
1797
|
+
const { actor, runtime } = makeActor({});
|
|
1798
|
+
runtime.calls.length = 0;
|
|
1799
|
+
|
|
1800
|
+
await actor.receiveTextFrame('INFO\r\n');
|
|
1801
|
+
|
|
1802
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1803
|
+
expect(sends).toHaveLength(1);
|
|
1804
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1805
|
+
expect(lines.some((l) => l.text.includes(' 371 '))).toBe(true);
|
|
1806
|
+
expect(lines.some((l) => l.text.includes(' 374 '))).toBe(true);
|
|
1807
|
+
expect(lines.every((l) => !l.text.includes(' 421 '))).toBe(true);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it('routes USERHOST resolving online nicks (302, never 421)', async () => {
|
|
1811
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1812
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1813
|
+
bobState.nick = 'bob';
|
|
1814
|
+
bobState.user = 'bobuser';
|
|
1815
|
+
bobState.host = 'bobhost';
|
|
1816
|
+
bobState.registration = 'registered';
|
|
1817
|
+
runtime.addConnection(conn);
|
|
1818
|
+
runtime.addConnection(bobState);
|
|
1819
|
+
runtime.calls.length = 0;
|
|
1820
|
+
|
|
1821
|
+
await actor.receiveTextFrame('USERHOST bob ghost\r\n');
|
|
1822
|
+
|
|
1823
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1824
|
+
expect(sends).toHaveLength(1);
|
|
1825
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1826
|
+
expect(lines[0]?.text).toContain(' 302 ');
|
|
1827
|
+
expect(lines[0]?.text).toContain('bob=+bobuser@bobhost');
|
|
1828
|
+
// Offline nick `ghost` is omitted from the reply.
|
|
1829
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
1830
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
it('USERHOST marks away users with - and opers with *', async () => {
|
|
1834
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1835
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1836
|
+
bobState.nick = 'bob';
|
|
1837
|
+
bobState.user = 'bobuser';
|
|
1838
|
+
bobState.host = 'bobhost';
|
|
1839
|
+
bobState.away = 'brb';
|
|
1840
|
+
bobState.userModes.oper = true;
|
|
1841
|
+
bobState.registration = 'registered';
|
|
1842
|
+
runtime.addConnection(conn);
|
|
1843
|
+
runtime.addConnection(bobState);
|
|
1844
|
+
runtime.calls.length = 0;
|
|
1845
|
+
|
|
1846
|
+
await actor.receiveTextFrame('USERHOST bob\r\n');
|
|
1847
|
+
|
|
1848
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1849
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1850
|
+
expect(lines[0]?.text).toContain('bob*=-bobuser@bobhost');
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it('routes ISON resolving online nicks (303, never 421)', async () => {
|
|
1854
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1855
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1856
|
+
bobState.nick = 'bob';
|
|
1857
|
+
bobState.registration = 'registered';
|
|
1858
|
+
runtime.addConnection(conn);
|
|
1859
|
+
runtime.addConnection(bobState);
|
|
1860
|
+
runtime.calls.length = 0;
|
|
1861
|
+
|
|
1862
|
+
await actor.receiveTextFrame('ISON bob ghost\r\n');
|
|
1863
|
+
|
|
1864
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1865
|
+
expect(sends).toHaveLength(1);
|
|
1866
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1867
|
+
expect(lines[0]?.text).toContain(' 303 ');
|
|
1868
|
+
expect(lines[0]?.text).toContain('bob');
|
|
1869
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
1870
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
it('ISON matches case-insensitively and reports the registered spelling', async () => {
|
|
1874
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1875
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1876
|
+
bobState.nick = 'Bob';
|
|
1877
|
+
bobState.registration = 'registered';
|
|
1878
|
+
runtime.addConnection(conn);
|
|
1879
|
+
runtime.addConnection(bobState);
|
|
1880
|
+
runtime.calls.length = 0;
|
|
1881
|
+
|
|
1882
|
+
await actor.receiveTextFrame('ISON BOB\r\n');
|
|
1883
|
+
|
|
1884
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1885
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1886
|
+
expect(lines[0]?.text).toContain(':Bob');
|
|
1887
|
+
});
|
|
1888
|
+
});
|
|
@@ -146,23 +146,6 @@ describe('dispatch', () => {
|
|
|
146
146
|
expect(calls[0]).toEqual({ method: 'applyChannelDelta', args: ['#foo', delta] });
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
it('invokes lookupNick, getConnectionInfo, getChannelSnapshot lookups', async () => {
|
|
150
|
-
const calls: RecordedCall[] = [];
|
|
151
|
-
await dispatch(
|
|
152
|
-
[
|
|
153
|
-
Effect.lookupNick('alice'),
|
|
154
|
-
Effect.getConnectionInfo('c1'),
|
|
155
|
-
Effect.getChannelSnapshot('#foo'),
|
|
156
|
-
],
|
|
157
|
-
fakeRuntime(calls),
|
|
158
|
-
);
|
|
159
|
-
expect(calls.map((c) => c.method)).toEqual([
|
|
160
|
-
'lookupNick',
|
|
161
|
-
'getConnectionInfo',
|
|
162
|
-
'getChannelSnapshot',
|
|
163
|
-
]);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
149
|
it('awaits each effect in sequence (one fails, the rest after it do not run)', async () => {
|
|
167
150
|
const calls: RecordedCall[] = [];
|
|
168
151
|
const failing: IrcRuntime = {
|
|
@@ -109,10 +109,12 @@ describe('buildRoutedReducers', () => {
|
|
|
109
109
|
const reducers = buildRoutedReducers(baseServerConfig);
|
|
110
110
|
expect(Object.keys(reducers).sort()).toEqual(
|
|
111
111
|
[
|
|
112
|
+
'admin',
|
|
112
113
|
'authenticate',
|
|
113
114
|
'away',
|
|
114
115
|
'cap',
|
|
115
116
|
'channelMode',
|
|
117
|
+
'info',
|
|
116
118
|
'invite',
|
|
117
119
|
'join',
|
|
118
120
|
'kick',
|
|
@@ -131,9 +133,11 @@ describe('buildRoutedReducers', () => {
|
|
|
131
133
|
'quit',
|
|
132
134
|
'tagmsgChannel',
|
|
133
135
|
'tagmsgUser',
|
|
136
|
+
'time',
|
|
134
137
|
'topic',
|
|
135
138
|
'user',
|
|
136
139
|
'userMode',
|
|
140
|
+
'version',
|
|
137
141
|
].sort(),
|
|
138
142
|
);
|
|
139
143
|
});
|