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.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { FakeClock } from '../src/ports';
3
+ import {
4
+ InMemoryNickHistoryStore,
5
+ type NickHistoryEntry,
6
+ type NickHistoryStore,
7
+ } from '../src/ports';
8
+
9
+ /** Builds a history entry with sensible defaults for tests. */
10
+ function entry(
11
+ nick: string,
12
+ signoffTime: number,
13
+ opts: Partial<NickHistoryEntry> = {},
14
+ ): NickHistoryEntry {
15
+ return {
16
+ nick,
17
+ connId: opts.connId ?? 'c1',
18
+ signoffTime,
19
+ ...(opts.username !== undefined ? { username: opts.username } : {}),
20
+ ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
21
+ ...(opts.realname !== undefined ? { realname: opts.realname } : {}),
22
+ };
23
+ }
24
+
25
+ describe('InMemoryNickHistoryStore — record + query', () => {
26
+ it('implements the NickHistoryStore port', () => {
27
+ const store: NickHistoryStore = new InMemoryNickHistoryStore(new FakeClock(1_000));
28
+ expect(store).toBeDefined();
29
+ });
30
+
31
+ it('returns a recorded entry on query (most-recent-first)', () => {
32
+ const clock = new FakeClock(1_000);
33
+ const store = new InMemoryNickHistoryStore(clock);
34
+ store.record(entry('alice', 1_000, { username: 'alice', hostname: 'ex.org' }));
35
+
36
+ const out = store.query('alice', 10);
37
+
38
+ expect(out).toHaveLength(1);
39
+ expect(out[0]?.nick).toBe('alice');
40
+ expect(out[0]?.username).toBe('alice');
41
+ expect(out[0]?.hostname).toBe('ex.org');
42
+ expect(out[0]?.signoffTime).toBe(1_000);
43
+ });
44
+
45
+ it('returns multiple entries most-recent-first', () => {
46
+ const clock = new FakeClock(1_000);
47
+ const store = new InMemoryNickHistoryStore(clock);
48
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
49
+ clock.advance(500);
50
+ store.record(entry('alice', 1_500, { connId: 'c2' }));
51
+ clock.advance(500);
52
+ store.record(entry('alice', 2_000, { connId: 'c3' }));
53
+
54
+ const out = store.query('alice', 10);
55
+ expect(out.map((e) => e.connId)).toEqual(['c3', 'c2', 'c1']);
56
+ });
57
+
58
+ it('caps the result at the requested count', () => {
59
+ const clock = new FakeClock(1_000);
60
+ const store = new InMemoryNickHistoryStore(clock);
61
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
62
+ clock.advance(500);
63
+ store.record(entry('alice', 1_500, { connId: 'c2' }));
64
+ clock.advance(500);
65
+ store.record(entry('alice', 2_000, { connId: 'c3' }));
66
+
67
+ const out = store.query('alice', 2);
68
+
69
+ expect(out.map((e) => e.connId)).toEqual(['c3', 'c2']);
70
+ });
71
+
72
+ it('returns an empty array for a nick with no history', () => {
73
+ const clock = new FakeClock(1_000);
74
+ const store = new InMemoryNickHistoryStore(clock);
75
+ expect(store.query('ghost', 10)).toEqual([]);
76
+ });
77
+
78
+ it('matches nicks case-insensitively (rfc1459 fold)', () => {
79
+ const clock = new FakeClock(1_000);
80
+ const store = new InMemoryNickHistoryStore(clock);
81
+ store.record(entry('Ali[e', 1_000));
82
+
83
+ expect(store.query('ali{e', 10)).toHaveLength(1);
84
+ expect(store.query('ALI{E', 10)).toHaveLength(1);
85
+ expect(store.query('ALI[E', 10)).toHaveLength(1);
86
+ });
87
+
88
+ it('returns an empty array when count is zero', () => {
89
+ const clock = new FakeClock(1_000);
90
+ const store = new InMemoryNickHistoryStore(clock);
91
+ store.record(entry('alice', 1_000));
92
+
93
+ expect(store.query('alice', 0)).toEqual([]);
94
+ });
95
+
96
+ it('keeps nick histories independent', () => {
97
+ const clock = new FakeClock(1_000);
98
+ const store = new InMemoryNickHistoryStore(clock);
99
+ store.record(entry('alice', 1_000));
100
+ clock.advance(500);
101
+ store.record(entry('bob', 1_500));
102
+
103
+ expect(store.query('alice', 10)).toHaveLength(1);
104
+ expect(store.query('bob', 10)).toHaveLength(1);
105
+ });
106
+ });
107
+
108
+ describe('InMemoryNickHistoryStore — TTL purge', () => {
109
+ it('purges entries older than maxAge on query', () => {
110
+ const clock = new FakeClock(1_000);
111
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 5_000 });
112
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
113
+ clock.advance(3_000);
114
+ store.record(entry('alice', 4_000, { connId: 'c2' }));
115
+ clock.advance(3_000);
116
+
117
+ const out = store.query('alice', 10);
118
+
119
+ expect(out.map((e) => e.connId)).toEqual(['c2']);
120
+ });
121
+
122
+ it('purges all entries once they exceed maxAge', () => {
123
+ const clock = new FakeClock(1_000);
124
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
125
+ store.record(entry('alice', 1_000));
126
+ clock.advance(2_000);
127
+
128
+ expect(store.query('alice', 10)).toEqual([]);
129
+ });
130
+
131
+ it('purges on record so the per-nick buffer does not grow unbounded', () => {
132
+ const clock = new FakeClock(1_000);
133
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
134
+ for (let i = 0; i < 50; i++) {
135
+ store.record(entry('alice', clock.now(), { connId: `c${i}` }));
136
+ clock.advance(100);
137
+ }
138
+ // clock.now() is now 6_000; entries older than cutoff 5_000 are purged.
139
+ const out = store.query('alice', 100);
140
+ expect(out.length).toBeLessThan(50);
141
+ });
142
+
143
+ it('purges to an empty array when all entries expire, then query returns empty', () => {
144
+ const clock = new FakeClock(1_000);
145
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
146
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
147
+ clock.advance(2_000);
148
+ // First query purges the lone entry → empty buffer remains in the map.
149
+ expect(store.query('alice', 10)).toEqual([]);
150
+ // Second query hits purgeExpired on the now-empty buffer (no-op splice).
151
+ expect(store.query('alice', 10)).toEqual([]);
152
+ });
153
+
154
+ it('uses a sane default maxAge when omitted', () => {
155
+ const clock = new FakeClock(1_000);
156
+ const store = new InMemoryNickHistoryStore(clock);
157
+ store.record(entry('alice', 1_000));
158
+ clock.advance(10_000);
159
+
160
+ expect(store.query('alice', 10)).toHaveLength(1);
161
+ });
162
+ });
@@ -23,6 +23,18 @@ describe('Numerics registry', () => {
23
23
  expect(Numerics.ERR_NEEDMOREPARAMS).toBe(461);
24
24
  });
25
25
 
26
+ it('maps RPL_WHOWASUSER to 314', () => {
27
+ expect(Numerics.RPL_WHOWASUSER).toBe(314);
28
+ });
29
+
30
+ it('maps RPL_ENDOFWHOWAS to 369', () => {
31
+ expect(Numerics.RPL_ENDOFWHOWAS).toBe(369);
32
+ });
33
+
34
+ it('maps ERR_WASNOSUCHNICK to 406', () => {
35
+ expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);
36
+ });
37
+
26
38
  it('maps ERR_NICKNAMEINUSE to 433', () => {
27
39
  expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);
28
40
  });
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { RawLine } from '../src/effects';
3
3
  import { Effect as EffectValue } from '../src/effects';
4
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../src/ports';
4
+ import {
5
+ EmptyMotdProvider,
6
+ FakeClock,
7
+ InMemoryNickHistoryStore,
8
+ SequentialIdFactory,
9
+ } from '../src/ports';
5
10
  import { createConnection } from '../src/state/connection';
6
11
  import {
7
12
  type Ctx,
@@ -97,6 +102,35 @@ describe('Ctx / Reducer types', () => {
97
102
  const keys = Object.keys(ctx) as Array<keyof Ctx>;
98
103
  expect(keys).not.toContain('msg');
99
104
  });
105
+
106
+ it('threads an optional history store through buildCtx onto ctx.history', () => {
107
+ const history = new InMemoryNickHistoryStore(new FakeClock(0));
108
+ const serverConfig: ServerConfig = {
109
+ serverName: 'irc.example.com',
110
+ networkName: 'ExampleNet',
111
+ maxChannelsPerUser: 30,
112
+ maxTargetsPerCommand: 10,
113
+ maxListEntries: 50,
114
+ nickLen: 30,
115
+ channelLen: 50,
116
+ topicLen: 390,
117
+ quitMessage: 'Client Quit',
118
+ };
119
+ const ctx = buildCtx({
120
+ serverConfig,
121
+ clock: new FakeClock(1_000),
122
+ ids: new SequentialIdFactory(),
123
+ motd: EmptyMotdProvider,
124
+ connection: createConnection({ id: 'c1', connectedSince: 0 }),
125
+ history,
126
+ });
127
+ expect(ctx.history).toBe(history);
128
+ });
129
+
130
+ it('omits ctx.history when no history store is supplied', () => {
131
+ const ctx = makeCtx();
132
+ expect(ctx.history).toBeUndefined();
133
+ });
100
134
  });
101
135
 
102
136
  describe('ServerConfig shape', () => {
@@ -5,7 +5,7 @@
5
5
  "rootDir": "./src",
6
6
  "outDir": "./dist",
7
7
  "tsBuildInfoFile": "./.tsbuildinfo",
8
- "types": []
8
+ "types": ["node"]
9
9
  },
10
10
  "include": ["src/**/*.ts"],
11
11
  "exclude": ["dist", "tests", "**/*.test.ts"]
@@ -2,7 +2,7 @@
2
2
  "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "noEmit": true,
5
- "types": [],
5
+ "types": ["node"],
6
6
  "composite": false
7
7
  },
8
8
  "include": ["src/**/*.ts", "tests/**/*.ts"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -30,6 +30,7 @@ import {
30
30
  type ChanName,
31
31
  type ChannelState,
32
32
  type Clock,
33
+ type ConnSnapshot,
33
34
  type ConnectionState,
34
35
  Effect,
35
36
  type Effect as EffectType,
@@ -39,6 +40,8 @@ import {
39
40
  type Logger,
40
41
  type MessageStore,
41
42
  type MotdProvider,
43
+ type MtlsIdentityProvider,
44
+ type NickHistoryStore,
42
45
  NoopLoggerInstance,
43
46
  Numerics,
44
47
  type RawLine,
@@ -47,15 +50,20 @@ import {
47
50
  chathistoryReducer,
48
51
  handleJoinZero,
49
52
  isChannelTarget,
53
+ isonReducer,
50
54
  listReducer,
51
55
  parse,
56
+ toSnapshot,
57
+ userhostReducer,
52
58
  whoReducer,
53
59
  whoisReducer,
60
+ whowasReducer,
54
61
  } from '@serverless-ircd/irc-core';
55
62
  import { dispatch } from './dispatch.js';
56
63
  import type { DispatchOptions } from './dispatch.js';
57
64
  import { type RoutedReducers, buildRoutedReducers } from './routing.js';
58
65
  import type { IrcRuntime } from './runtime.js';
66
+ import { type Transport, WsTextFrameTransport } from './transport.js';
59
67
 
60
68
  /**
61
69
  * Channel-state access for the actor.
@@ -114,6 +122,22 @@ export interface ConnectionActorOptions {
114
122
  * can vouch for the credentials.
115
123
  */
116
124
  readonly accounts?: AccountStore;
125
+ /**
126
+ * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
127
+ * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
128
+ * `AUTHENTICATE EXTERNAL` reducer can resolve the connection's verified
129
+ * certificate subject. Omitted (→ `ctx.mtlsIdentity` undefined) means
130
+ * EXTERNAL is rejected with `904 ERR_SASLFAIL`.
131
+ */
132
+ readonly mtlsIdentity?: MtlsIdentityProvider;
133
+ /**
134
+ * Nick-history source for the `WHOWAS` command (absent when WHOWAS
135
+ * history retention is disabled). When bound, `ctx.history` is defined
136
+ * so the QUIT and NICK-change reducers record sign-off entries and the
137
+ * WHOWAS reducer replays them. Omitted (→ `ctx.history` undefined)
138
+ * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
139
+ */
140
+ readonly history?: NickHistoryStore;
117
141
  /**
118
142
  * Structured-logger port. When omitted the actor runs
119
143
  * silently — observability is purely opt-in. When supplied, every
@@ -127,6 +151,15 @@ export interface ConnectionActorOptions {
127
151
  * still uniquely identifiable in logs.
128
152
  */
129
153
  readonly traceId?: string;
154
+ /**
155
+ * Line-framing seam (PLAN §9, ADR-009). Turns inbound transport input
156
+ * into complete IRC lines. Defaults to a {@link WsTextFrameTransport}
157
+ * (one message per WebSocket text frame, the serverless default). A raw
158
+ * TCP+TLS adapter injects a {@link TcpByteStreamTransport} for stateful
159
+ * `\r\n` byte-stream framing across chunks. The parser/reducer/dispatch
160
+ * pipeline is shared unchanged across both transports.
161
+ */
162
+ readonly transport?: Transport;
130
163
  }
131
164
 
132
165
  export class ConnectionActor {
@@ -139,7 +172,16 @@ export class ConnectionActor {
139
172
  private readonly motd: MotdProvider;
140
173
  private readonly messages: MessageStore | undefined;
141
174
  private readonly accounts: AccountStore | undefined;
175
+ private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
176
+ private readonly history: NickHistoryStore | undefined;
142
177
  private readonly reducers: RoutedReducers;
178
+ /**
179
+ * Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
180
+ * chunk) into complete IRC lines. Defaulted to a WS-frame transport for
181
+ * the serverless default; a TCP+TLS adapter injects the stateful
182
+ * byte-stream transport at construction.
183
+ */
184
+ private readonly transport: Transport;
143
185
  /**
144
186
  * Bound logger. Defaults to a no-op so the actor pipeline has no
145
187
  * observable side effects when callers do not opt in.
@@ -163,6 +205,8 @@ export class ConnectionActor {
163
205
  this.motd = opts.motd ?? EmptyMotdProvider;
164
206
  this.messages = opts.messages;
165
207
  this.accounts = opts.accounts;
208
+ this.mtlsIdentity = opts.mtlsIdentity;
209
+ this.history = opts.history;
166
210
  this.logger = opts.logger ?? NoopLoggerInstance;
167
211
  this.pinnedTraceId = opts.traceId;
168
212
  // Bind the connection id once so every record the actor emits carries
@@ -176,15 +220,19 @@ export class ConnectionActor {
176
220
  // reducer when flood control is disabled) so a flooding client is bounded
177
221
  // regardless of which authority owns the command's state.
178
222
  this.reducers = buildRoutedReducers(opts.serverConfig);
223
+ this.transport = opts.transport ?? new WsTextFrameTransport();
179
224
  }
180
225
 
181
226
  /**
182
- * Processes one WebSocket text frame. Splits on `\r\n` (tolerating bare
183
- * `\n`) and dispatches each non-empty line in order. A parse failure on
184
- * one line emits a `421` and processing continues with the next line.
227
+ * Transport-agnostic entry point: feeds `chunk` through the bound
228
+ * {@link Transport} and dispatches each complete, non-empty line in order.
229
+ * A WS adapter passes one text frame per call; a raw TCP+TLS adapter
230
+ * passes one arbitrary byte chunk per call and the transport buffers any
231
+ * partial trailing line across calls. A parse failure on one line emits a
232
+ * `421` and processing continues with the next line.
185
233
  */
186
- async receiveTextFrame(text: string): Promise<void> {
187
- const lines = splitFrameLines(text);
234
+ async receive(chunk: string): Promise<void> {
235
+ const lines = this.transport.feed(chunk);
188
236
  if (lines.length === 0) return;
189
237
  const traceId = this.pinnedTraceId ?? this.ids.traceId();
190
238
  const log = this.logger.child({ traceId });
@@ -194,6 +242,17 @@ export class ConnectionActor {
194
242
  }
195
243
  }
196
244
 
245
+ /**
246
+ * Processes one WebSocket text frame. Equivalent to {@link receive} — kept
247
+ * as the named entry point every WS adapter already calls. Splits on
248
+ * `\r\n` (tolerating bare `\n`) via the default WS-frame transport and
249
+ * dispatches each non-empty line in order. A parse failure on one line
250
+ * emits a `421` and processing continues with the next line.
251
+ */
252
+ async receiveTextFrame(text: string): Promise<void> {
253
+ await this.receive(text);
254
+ }
255
+
197
256
  private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
198
257
  let msg: IrcMessage;
199
258
  let parseFailed = false;
@@ -256,6 +315,8 @@ export class ConnectionActor {
256
315
  connection: this.state,
257
316
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
258
317
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
318
+ ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
319
+ ...(this.history !== undefined ? { history: this.history } : {}),
259
320
  });
260
321
 
261
322
  switch (msg.command) {
@@ -283,6 +344,20 @@ export class ConnectionActor {
283
344
  case 'AWAY':
284
345
  return this.reducers.away(this.state, msg, ctx).effects;
285
346
 
347
+ // -------- Server-info query commands --------
348
+ case 'VERSION':
349
+ return this.reducers.version(this.state, msg, ctx).effects;
350
+ case 'TIME':
351
+ return this.reducers.time(this.state, msg, ctx).effects;
352
+ case 'ADMIN':
353
+ return this.reducers.admin(this.state, msg, ctx).effects;
354
+ case 'INFO':
355
+ return this.reducers.info(this.state, msg, ctx).effects;
356
+ case 'USERHOST':
357
+ return this.routeUserhost(msg, ctx);
358
+ case 'ISON':
359
+ return this.routeIson(msg, ctx);
360
+
286
361
  // -------- Channel-authority commands --------
287
362
  case 'JOIN':
288
363
  return this.routeJoin(msg, ctx);
@@ -305,6 +380,8 @@ export class ConnectionActor {
305
380
  return this.routeWho(msg, ctx);
306
381
  case 'WHOIS':
307
382
  return this.routeWhois(msg, ctx);
383
+ case 'WHOWAS':
384
+ return this.routeWhowas(msg, ctx);
308
385
  case 'CHATHISTORY':
309
386
  return this.routeChathistory(msg, ctx);
310
387
 
@@ -339,6 +416,16 @@ export class ConnectionActor {
339
416
  : this.reducers.userMode(this.state, msg, ctx).effects;
340
417
 
341
418
  default:
419
+ // Remaining standard IRC verbs not yet implemented. They fall through
420
+ // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
421
+ // - Deferred (serverless-incompatible / S2S / needs new infra):
422
+ // LUSERS (needs cross-adapter getStats), KILL (oper disconnect),
423
+ // REHASH (no runtime config reload), CONNECT/SQUIT (S2S, PLAN
424
+ // non-goal), TRACE (complex routing info), STATS (stats backend),
425
+ // WALLOPS (oper broadcast mechanism), SETNAME (CHGHOST plumbing),
426
+ // LINKS (S2S server list).
427
+ // - Obsolete (RFC 2812, never widely implemented): SERVICE, SUMMON,
428
+ // USERS.
342
429
  return [this.unknownCommandEffect(msg.command)];
343
430
  }
344
431
  }
@@ -387,6 +474,72 @@ export class ConnectionActor {
387
474
  return whoisReducer(target, channels, msg, ctx).effects;
388
475
  }
389
476
 
477
+ /**
478
+ * Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
479
+ * command is query-only: it reads the bound nick-history store
480
+ * (`this.history`, absent when no store is wired) and emits
481
+ * `314`/`369`/`406` numerics. No runtime lookups are needed — the store
482
+ * is synchronous and self-contained.
483
+ */
484
+ private async routeWhowas(
485
+ msg: IrcMessage,
486
+ ctx: ReturnType<typeof buildCtx>,
487
+ ): Promise<EffectType[]> {
488
+ return whowasReducer(this.history, msg, ctx).effects;
489
+ }
490
+
491
+ /**
492
+ * Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
493
+ * requested nick via the nick registry + connection lookup, passing the
494
+ * resulting snapshots (or `null` for offline nicks) to the pure formatter.
495
+ * The number of queried nicks is capped at
496
+ * `serverConfig.maxTargetsPerCommand` by the reducer.
497
+ */
498
+ private async routeUserhost(
499
+ msg: IrcMessage,
500
+ ctx: ReturnType<typeof buildCtx>,
501
+ ): Promise<EffectType[]> {
502
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
503
+ const nicks = msg.params.slice(0, cap);
504
+ const resolved = new Map<string, ConnSnapshot | null>();
505
+ for (const nick of nicks) {
506
+ if (nick.length === 0) continue;
507
+ const connId = await this.runtime.lookupNick(nick);
508
+ if (connId === null) {
509
+ resolved.set(nick, null);
510
+ continue;
511
+ }
512
+ const conn = await this.runtime.getConnection(connId);
513
+ resolved.set(nick, conn === null ? null : toSnapshot(conn));
514
+ }
515
+ return userhostReducer(resolved, msg, ctx).effects;
516
+ }
517
+
518
+ /**
519
+ * Routes `ISON <nick>{ <nick>}` to the ison reducer. Resolves each requested
520
+ * nick via the nick registry; for online nicks, fetches the connection to
521
+ * recover the registered (display) spelling. Passes a folded→display map to
522
+ * the pure formatter.
523
+ */
524
+ private async routeIson(
525
+ msg: IrcMessage,
526
+ ctx: ReturnType<typeof buildCtx>,
527
+ ): Promise<EffectType[]> {
528
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
529
+ const nicks = msg.params.slice(0, cap);
530
+ const online = new Map<string, string>();
531
+ for (const nick of nicks) {
532
+ if (nick.length === 0) continue;
533
+ const connId = await this.runtime.lookupNick(nick);
534
+ if (connId === null) continue;
535
+ const conn = await this.runtime.getConnection(connId);
536
+ if (conn?.nick !== undefined) {
537
+ online.set(conn.nick.toLowerCase(), conn.nick);
538
+ }
539
+ }
540
+ return isonReducer(online, msg, ctx).effects;
541
+ }
542
+
390
543
  /**
391
544
  * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
392
545
  * reducer. The command is query-only and must NOT create a channel as a
@@ -531,15 +684,6 @@ export class ConnectionActor {
531
684
  }
532
685
  }
533
686
 
534
- /**
535
- * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
536
- * and missing trailing terminators. Returns each line bare (no CRLF).
537
- */
538
- export function splitFrameLines(text: string): string[] {
539
- if (text.length === 0) return [];
540
- return text.split(/\r?\n/u);
541
- }
542
-
543
687
  /** Returns the first whitespace-delimited token of `line`, or `''`. */
544
688
  function firstToken(line: string): string {
545
689
  const m = line.match(/^\S+/u);
@@ -33,9 +33,6 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
33
33
  ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
34
34
  ReleaseNick: (e, r) => r.releaseNick(e.nick),
35
35
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
36
- LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
37
- GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
38
- GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
39
36
  };
40
37
 
41
38
  function noop(): void {}
@@ -3,11 +3,19 @@
3
3
  *
4
4
  * Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
5
5
  * interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
6
- * that turns a WebSocket text frame into reducer invocations + dispatch.
6
+ * that turns inbound transport input (a WebSocket text frame or a raw TCP byte
7
+ * chunk) into reducer invocations + dispatch via the transport-agnostic
8
+ * {@link Transport} framing seam.
7
9
  */
8
10
  export type { IrcRuntime } from './runtime.js';
9
11
  export { dispatch } from './dispatch.js';
10
- export { ConnectionActor, splitFrameLines } from './actor.js';
12
+ export { ConnectionActor } from './actor.js';
11
13
  export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
14
+ export {
15
+ splitFrameLines,
16
+ WsTextFrameTransport,
17
+ TcpByteStreamTransport,
18
+ } from './transport.js';
19
+ export type { Transport } from './transport.js';
12
20
  export { buildRoutedReducers, resolveFloodControl } from './routing.js';
13
21
  export type { RoutedReducers } from './routing.js';
@@ -29,11 +29,13 @@ import {
29
29
  type FloodControlConfig,
30
30
  type Reducer,
31
31
  type ServerConfig,
32
+ adminReducer,
32
33
  authenticateReducer,
33
34
  awayReducer,
34
35
  capReducer,
35
36
  channelModeReducer,
36
37
  defaultCommandCost,
38
+ infoReducer,
37
39
  inviteReducer,
38
40
  joinReducer,
39
41
  kickReducer,
@@ -52,9 +54,11 @@ import {
52
54
  quitReducer,
53
55
  tagmsgChannelReducer,
54
56
  tagmsgUserReducer,
57
+ timeReducer,
55
58
  topicReducer,
56
59
  userModeReducer,
57
60
  userReducer,
61
+ versionReducer,
58
62
  wrapWithFloodControl,
59
63
  } from '@serverless-ircd/irc-core';
60
64
 
@@ -76,6 +80,10 @@ export interface RoutedReducers {
76
80
  readonly cap: Reducer<ConnectionState>;
77
81
  readonly authenticate: Reducer<ConnectionState>;
78
82
  readonly away: Reducer<ConnectionState>;
83
+ readonly version: Reducer<ConnectionState>;
84
+ readonly time: Reducer<ConnectionState>;
85
+ readonly admin: Reducer<ConnectionState>;
86
+ readonly info: Reducer<ConnectionState>;
79
87
  // Channel-authority reducers.
80
88
  readonly join: Reducer<ChannelState>;
81
89
  readonly part: Reducer<ChannelState>;
@@ -142,6 +150,10 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
142
150
  cap: wrap(capReducer),
143
151
  authenticate: wrap(authenticateReducer),
144
152
  away: wrap(awayReducer),
153
+ version: wrap(versionReducer),
154
+ time: wrap(timeReducer),
155
+ admin: wrap(adminReducer),
156
+ info: wrap(infoReducer),
145
157
  join: wrap(joinReducer),
146
158
  part: wrap(partReducer),
147
159
  topic: wrap(topicReducer),