serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Unit tests for `loadServerConfigFromLambdaEnv` and `buildLambdaConfigInput`.
3
+ *
4
+ * Pure-function tests; no DynamoDB / Lambda runtime needed.
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+ import {
9
+ type LambdaConfigEnv,
10
+ buildLambdaConfigInput,
11
+ loadServerConfigFromLambdaEnv,
12
+ } from '../src/config-loader.js';
13
+
14
+ describe('buildLambdaConfigInput', () => {
15
+ it('passes SERVER_NAME and NETWORK_NAME through', () => {
16
+ const out = buildLambdaConfigInput({ SERVER_NAME: 'irc.x', NETWORK_NAME: 'NetX' });
17
+ expect(out.serverName).toBe('irc.x');
18
+ expect(out.networkName).toBe('NetX');
19
+ });
20
+
21
+ it('splits MOTD on newlines into motdLines', () => {
22
+ const out = buildLambdaConfigInput({ MOTD: 'first\nsecond' });
23
+ expect(out.motdLines).toEqual(['first', 'second']);
24
+ });
25
+
26
+ it('omits motdLines when MOTD is empty', () => {
27
+ const out = buildLambdaConfigInput({ MOTD: '' });
28
+ expect('motdLines' in out).toBe(false);
29
+ });
30
+
31
+ it('omits motdLines when MOTD is undefined', () => {
32
+ const out = buildLambdaConfigInput({});
33
+ expect('motdLines' in out).toBe(false);
34
+ });
35
+
36
+ it('parses MAX_CLIENTS as int', () => {
37
+ expect(buildLambdaConfigInput({ MAX_CLIENTS: '42' }).maxClients).toBe(42);
38
+ });
39
+
40
+ it('passes CHANNEL_PREFIXES through', () => {
41
+ expect(buildLambdaConfigInput({ CHANNEL_PREFIXES: '#&' }).channelPrefixes).toBe('#&');
42
+ });
43
+
44
+ it('builds operCreds when OPER_USER alone is set', () => {
45
+ const out = buildLambdaConfigInput({ OPER_USER: 'alice' });
46
+ expect(out.operCreds).toEqual([{ user: 'alice', password: '' }]);
47
+ });
48
+
49
+ it('builds operCreds when OPER_PASSWORD alone is set', () => {
50
+ const out = buildLambdaConfigInput({ OPER_PASSWORD: 'pw' });
51
+ expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
52
+ });
53
+
54
+ it('omits operCreds when neither OPER_* is set', () => {
55
+ expect('operCreds' in buildLambdaConfigInput({})).toBe(false);
56
+ });
57
+
58
+ it('parses all numeric knobs', () => {
59
+ const out = buildLambdaConfigInput({
60
+ MAX_CHANNELS_PER_USER: '5',
61
+ MAX_TARGETS_PER_COMMAND: '3',
62
+ NICK_LEN: '9',
63
+ CHANNEL_LEN: '12',
64
+ TOPIC_LEN: '100',
65
+ MAX_LIST_ENTRIES: '7',
66
+ });
67
+ expect(out.maxChannelsPerUser).toBe(5);
68
+ expect(out.maxTargetsPerCommand).toBe(3);
69
+ expect(out.nickLen).toBe(9);
70
+ expect(out.channelLen).toBe(12);
71
+ expect(out.topicLen).toBe(100);
72
+ expect(out.maxListEntries).toBe(7);
73
+ });
74
+
75
+ it('passes QUIT_MESSAGE through', () => {
76
+ expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
77
+ });
78
+
79
+ it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
80
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
81
+ expect(out.saslAccounts).toEqual([
82
+ { username: 'alice', password: 'secret' },
83
+ { username: 'bob', password: 'b-pass' },
84
+ ]);
85
+ });
86
+
87
+ it('omits saslAccounts when SASL_ACCOUNTS is empty', () => {
88
+ expect('saslAccounts' in buildLambdaConfigInput({ SASL_ACCOUNTS: '' })).toBe(false);
89
+ });
90
+
91
+ it('omits saslAccounts when SASL_ACCOUNTS is undefined', () => {
92
+ expect('saslAccounts' in buildLambdaConfigInput({})).toBe(false);
93
+ });
94
+
95
+ it('skips blank lines in SASL_ACCOUNTS (trailing newline tolerance)', () => {
96
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\n\n \n' });
97
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
98
+ });
99
+
100
+ it('skips SASL_ACCOUNTS entries with no colon separator', () => {
101
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'nopass\nalice:secret' });
102
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
103
+ });
104
+
105
+ it('skips SASL_ACCOUNTS entries with an empty username (leading colon)', () => {
106
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ':onlypass\nalice:secret' });
107
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
108
+ });
109
+
110
+ it('skips SASL_ACCOUNTS entries with an empty password', () => {
111
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:\nbob:real' });
112
+ expect(out.saslAccounts).toEqual([{ username: 'bob', password: 'real' }]);
113
+ });
114
+
115
+ it('trims whole SASL_ACCOUNTS lines but preserves fields around the colon', () => {
116
+ // Only the line is trimmed; whitespace adjacent to ':' is kept (so a
117
+ // stray space does not silently change the stored username/password).
118
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
119
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
120
+ });
121
+ });
122
+
123
+ describe('loadServerConfigFromLambdaEnv', () => {
124
+ it('parses a minimal valid env', () => {
125
+ const env: LambdaConfigEnv = { SERVER_NAME: 'irc.test', NETWORK_NAME: 'TestNet' };
126
+ const cfg = loadServerConfigFromLambdaEnv(env);
127
+ expect(cfg.serverName).toBe('irc.test');
128
+ expect(cfg.networkName).toBe('TestNet');
129
+ });
130
+
131
+ it('applies schema defaults for unspecified numeric knobs', () => {
132
+ const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
133
+ expect(typeof cfg.maxClients).toBe('number');
134
+ expect(typeof cfg.nickLen).toBe('number');
135
+ });
136
+
137
+ it('throws when SERVER_NAME is missing', () => {
138
+ expect(() => loadServerConfigFromLambdaEnv({})).toThrow();
139
+ });
140
+
141
+ it('round-trips MOTD lines into ParsedServerConfig', () => {
142
+ const cfg = loadServerConfigFromLambdaEnv({
143
+ SERVER_NAME: 's',
144
+ NETWORK_NAME: 'n',
145
+ MOTD: 'a\nb',
146
+ });
147
+ expect(cfg.motdLines).toEqual(['a', 'b']);
148
+ });
149
+
150
+ it('round-trips the CDK default MOTD (newline-joined) into exactly two motdLines', () => {
151
+ const cdkDefaultMotd =
152
+ 'Welcome to the ServerlessIRCd deployment.\nThis server runs on AWS Lambda.';
153
+ const cfg = loadServerConfigFromLambdaEnv({
154
+ SERVER_NAME: 's',
155
+ NETWORK_NAME: 'n',
156
+ MOTD: cdkDefaultMotd,
157
+ });
158
+ expect(cfg.motdLines).toHaveLength(2);
159
+ expect(cfg.motdLines).toEqual([
160
+ 'Welcome to the ServerlessIRCd deployment.',
161
+ 'This server runs on AWS Lambda.',
162
+ ]);
163
+ });
164
+
165
+ it('treats a comma as part of a line, not a line delimiter', () => {
166
+ const cfg = loadServerConfigFromLambdaEnv({
167
+ SERVER_NAME: 's',
168
+ NETWORK_NAME: 'n',
169
+ MOTD: 'first line, still first line',
170
+ });
171
+ expect(cfg.motdLines).toEqual(['first line, still first line']);
172
+ expect(cfg.motdLines).toHaveLength(1);
173
+ });
174
+
175
+ it('honours CHANNEL_PREFIXES override', () => {
176
+ const cfg = loadServerConfigFromLambdaEnv({
177
+ SERVER_NAME: 's',
178
+ NETWORK_NAME: 'n',
179
+ CHANNEL_PREFIXES: '&',
180
+ });
181
+ expect(cfg.channelPrefixes).toBe('&');
182
+ });
183
+
184
+ it('honours MAX_CLIENTS override', () => {
185
+ const cfg = loadServerConfigFromLambdaEnv({
186
+ SERVER_NAME: 's',
187
+ NETWORK_NAME: 'n',
188
+ MAX_CLIENTS: '5000',
189
+ });
190
+ expect(cfg.maxClients).toBe(5000);
191
+ });
192
+
193
+ it('honours QUIT_MESSAGE override', () => {
194
+ const cfg = loadServerConfigFromLambdaEnv({
195
+ SERVER_NAME: 's',
196
+ NETWORK_NAME: 'n',
197
+ QUIT_MESSAGE: 'goodbye',
198
+ });
199
+ expect(cfg.quitMessage).toBe('goodbye');
200
+ });
201
+
202
+ it('round-trips SASL_ACCOUNTS into cfg.saslAccounts end-to-end', () => {
203
+ const cfg = loadServerConfigFromLambdaEnv({
204
+ SERVER_NAME: 's',
205
+ NETWORK_NAME: 'n',
206
+ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass',
207
+ });
208
+ expect(cfg.saslAccounts).toEqual([
209
+ { username: 'alice', password: 'secret' },
210
+ { username: 'bob', password: 'b-pass' },
211
+ ]);
212
+ });
213
+ });
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Integration tests for QUIT fanout on `$disconnect` (and on the
3
+ * `GoneException` cleanup path).
4
+ *
5
+ * Verifies that when connection A tears down, every peer sharing a
6
+ * channel with A receives the canonical `:A QUIT :reason` line via
7
+ * `managementApi.postToConnection`, and that a gone peer during fanout
8
+ * triggers lazy cleanup instead of crashing.
9
+ */
10
+
11
+ import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import {
15
+ DEFAULT_SERVER_CONFIG,
16
+ type Nick,
17
+ type ParsedServerConfig,
18
+ StaticMotdProvider,
19
+ createConnection,
20
+ } from '@serverless-ircd/irc-core';
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { AwsRuntime, cleanupConnection, type PostToConnection } from '../src/aws-runtime.js';
23
+ import { handleDisconnect } from '../src/handlers/disconnect.js';
24
+ import {
25
+ type HandlerDeps,
26
+ __resetHandlerCacheForTests,
27
+ dispatch,
28
+ } from '../src/handlers/index.js';
29
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
30
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
31
+ import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
32
+ import type { TablesConfig } from '../src/tables.js';
33
+
34
+ const available = process.env.DDB_AVAILABLE === '1';
35
+ const endpoint = process.env.DYNAMO_ENDPOINT;
36
+
37
+ const SERVER_CONFIG: ParsedServerConfig = {
38
+ ...DEFAULT_SERVER_CONFIG,
39
+ serverName: 'irc.example.com',
40
+ networkName: 'ExampleNet',
41
+ motdLines: [],
42
+ };
43
+
44
+ interface Fixture {
45
+ tables: TablesConfig;
46
+ client: ReturnType<typeof createDynamoDocumentClient>;
47
+ cleanup: () => Promise<void>;
48
+ }
49
+
50
+ let fx: Fixture | null;
51
+
52
+ async function makeFixture(): Promise<Fixture> {
53
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
54
+ const prefix = `q${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
55
+ const tables = Object.fromEntries(
56
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
57
+ ) as TablesConfig;
58
+ const client = createDynamoDocumentClient({ endpoint });
59
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
60
+ const pkName = props.partitionKey?.name;
61
+ const skName = props.sortKey?.name;
62
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
63
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
64
+ { AttributeName: pkName, AttributeType: 'S' },
65
+ ];
66
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
67
+ { AttributeName: pkName, KeyType: 'HASH' },
68
+ ];
69
+ if (skName !== undefined) {
70
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
71
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
72
+ }
73
+ await client.send(
74
+ new CreateTableCommand({
75
+ TableName: `${prefix}${logical}`,
76
+ AttributeDefinitions: attributeDefinitions,
77
+ KeySchema: keySchema,
78
+ BillingMode: 'PAY_PER_REQUEST',
79
+ }),
80
+ );
81
+ }
82
+ return {
83
+ tables,
84
+ client,
85
+ cleanup: async () => {
86
+ for (const logical of Object.keys(TABLE_DEFS)) {
87
+ try {
88
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
89
+ } catch {
90
+ // best-effort
91
+ }
92
+ }
93
+ },
94
+ };
95
+ }
96
+
97
+ /** Seed a registered connection with nick/user/host and a channel membership. */
98
+ async function seedConnection(
99
+ client: ReturnType<typeof createDynamoDocumentClient>,
100
+ tables: TablesConfig,
101
+ connId: string,
102
+ nick: string,
103
+ channel: string,
104
+ ): Promise<void> {
105
+ const now = Date.now();
106
+ const state = createConnection({ id: connId, connectedSince: now });
107
+ state.registration = 'registered';
108
+ state.nick = nick as Nick;
109
+ state.user = nick;
110
+ state.host = 'x';
111
+ state.realname = nick;
112
+ state.joinedChannels = new Set([channel as never]);
113
+ state.lastSeen = now;
114
+ await client.send(
115
+ new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
116
+ );
117
+ await client.send(
118
+ new PutCommand({
119
+ TableName: tables.Nicks,
120
+ Item: marshalNick(nick as Nick, connId, now),
121
+ }),
122
+ );
123
+ await client.send(
124
+ new PutCommand({
125
+ TableName: tables.ChannelMembers,
126
+ Item: marshalChannelMember(channel, {
127
+ conn: connId,
128
+ nick: nick as Nick,
129
+ op: false,
130
+ voice: false,
131
+ }),
132
+ }),
133
+ );
134
+ }
135
+
136
+ /** Records every `postToConnection` call keyed by ConnectionId. */
137
+ class RecordingPostToConnection implements PostToConnection {
138
+ readonly sent = new Map<string, string[]>();
139
+ private readonly gone = new Set<string>();
140
+ private readonly errorPeers = new Map<string, Error>();
141
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
142
+ const custom = this.errorPeers.get(input.ConnectionId);
143
+ if (custom !== undefined) throw custom;
144
+ if (this.gone.has(input.ConnectionId)) {
145
+ throw new GoneException({ $metadata: {}, message: 'Gone' });
146
+ }
147
+ const list = this.sent.get(input.ConnectionId) ?? [];
148
+ for (const line of input.Data.split('\r\n')) {
149
+ if (line.length > 0) list.push(line);
150
+ }
151
+ this.sent.set(input.ConnectionId, list);
152
+ return {};
153
+ }
154
+ markGone(connId: string): void {
155
+ this.gone.add(connId);
156
+ }
157
+ markError(connId: string, err: Error): void {
158
+ this.errorPeers.set(connId, err);
159
+ }
160
+ }
161
+
162
+ function probe(
163
+ client: ReturnType<typeof createDynamoDocumentClient>,
164
+ tables: TablesConfig,
165
+ ): AwsRuntime {
166
+ return new AwsRuntime({
167
+ dynamo: client,
168
+ tables,
169
+ connId: '__probe__',
170
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
171
+ managementApi: null,
172
+ });
173
+ }
174
+
175
+ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
176
+ beforeEach(async () => {
177
+ fx = await makeFixture();
178
+ });
179
+ afterEach(async () => {
180
+ if (fx) await fx.cleanup();
181
+ fx = null;
182
+ });
183
+
184
+ it('handleDisconnect fans the QUIT line out to every other channel member', async () => {
185
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
186
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
187
+
188
+ const mgmt = new RecordingPostToConnection();
189
+ await handleDisconnect({
190
+ dynamo: fx!.client,
191
+ tables: fx!.tables,
192
+ connectionId: 'a',
193
+ managementApi: mgmt,
194
+ });
195
+
196
+ // Peer b received the QUIT line for alice.
197
+ const bobReceived = mgmt.sent.get('b') ?? [];
198
+ expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
199
+ // The disconnecting connection never receives its own QUIT.
200
+ expect(mgmt.sent.has('a')).toBe(false);
201
+ });
202
+
203
+ it('handleDisconnect without a managementApi performs no fanout but still cleans up', async () => {
204
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
205
+ await handleDisconnect({
206
+ dynamo: fx!.client,
207
+ tables: fx!.tables,
208
+ connectionId: 'a',
209
+ managementApi: null,
210
+ });
211
+ const p = probe(fx!.client, fx!.tables);
212
+ expect(await p.getConnectionInfo('a')).toBeNull();
213
+ expect(await p.lookupNick('alice' as Nick)).toBeNull();
214
+ });
215
+
216
+ it('a never-registered connection disconnects without fanout (no nick)', async () => {
217
+ const mgmt = new RecordingPostToConnection();
218
+ await handleDisconnect({
219
+ dynamo: fx!.client,
220
+ tables: fx!.tables,
221
+ connectionId: 'nobody',
222
+ managementApi: mgmt,
223
+ });
224
+ expect(mgmt.sent.size).toBe(0);
225
+ });
226
+
227
+ it('fanout to a gone peer triggers lazy cleanup instead of crashing', async () => {
228
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
229
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
230
+
231
+ const mgmt = new RecordingPostToConnection();
232
+ mgmt.markGone('b');
233
+
234
+ await expect(
235
+ handleDisconnect({
236
+ dynamo: fx!.client,
237
+ tables: fx!.tables,
238
+ connectionId: 'a',
239
+ managementApi: mgmt,
240
+ }),
241
+ ).resolves.toBeUndefined();
242
+
243
+ // The gone peer b was lazily cleaned up (Connection row removed).
244
+ const p = probe(fx!.client, fx!.tables);
245
+ expect(await p.getConnectionInfo('b')).toBeNull();
246
+ });
247
+
248
+ it('cleanupConnection directly broadcasts QUIT when given a managementApi', async () => {
249
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
250
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
251
+
252
+ const mgmt = new RecordingPostToConnection();
253
+ await cleanupConnection(fx!.client, fx!.tables, 'a', mgmt);
254
+
255
+ const bobReceived = mgmt.sent.get('b') ?? [];
256
+ expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
257
+ const p = probe(fx!.client, fx!.tables);
258
+ expect(await p.getConnectionInfo('a')).toBeNull();
259
+ });
260
+
261
+ it('the $disconnect dispatch route fans QUIT out to peers via the management API', async () => {
262
+ const deps: HandlerDeps = {
263
+ dynamo: fx!.client,
264
+ tables: fx!.tables,
265
+ serverConfig: SERVER_CONFIG,
266
+ motd: new StaticMotdProvider([]),
267
+ messages: { record() {}, query: () => Promise.resolve([]) } as never,
268
+ managementApi: null,
269
+ };
270
+ // Register alice + bob into the same channel via $default frames.
271
+ const mgmt = new RecordingPostToConnection();
272
+ deps.managementApi = mgmt as never;
273
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'a' } }, deps);
274
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'b' } }, deps);
275
+ await dispatch(
276
+ { requestContext: { routeKey: '$default', connectionId: 'a' }, body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n' },
277
+ deps,
278
+ );
279
+ await dispatch(
280
+ { requestContext: { routeKey: '$default', connectionId: 'b' }, body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n' },
281
+ deps,
282
+ );
283
+ mgmt.sent.clear();
284
+
285
+ // alice disconnects.
286
+ await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'a' } }, deps);
287
+
288
+ const bobReceived = mgmt.sent.get('b') ?? [];
289
+ expect(bobReceived.some((l) => /^:alice!alice\S* QUIT :/.test(l))).toBe(true);
290
+ });
291
+
292
+ it('the send() GoneException cleanup path also broadcasts QUIT to the gone peer\'s channels', async () => {
293
+ // alice and carol share #room; bob shares a different channel with carol.
294
+ await seedConnection(fx!.client, fx!.tables, 'alice', 'alice', '#room');
295
+ await seedConnection(fx!.client, fx!.tables, 'carol', 'carol', '#room');
296
+
297
+ // A management double where alice is "gone" (raises GoneException).
298
+ const mgmt = new RecordingPostToConnection();
299
+ mgmt.markGone('alice');
300
+
301
+ const runtime = new AwsRuntime({
302
+ dynamo: fx!.client,
303
+ tables: fx!.tables,
304
+ connId: 'carol',
305
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
306
+ managementApi: mgmt,
307
+ });
308
+
309
+ // carol sends to alice → GoneException → cleanupConnection(alice)
310
+ // → alice's QUIT fans out to carol (the other #room member).
311
+ await runtime.send('alice', [{ text: ':carol!carol@x PRIVMSG alice :hi' } as never]);
312
+
313
+ const carolReceived = mgmt.sent.get('carol') ?? [];
314
+ expect(carolReceived.some((l) => l.startsWith(':alice!alice@x QUIT :'))).toBe(true);
315
+ // alice was cleaned up.
316
+ const p = probe(fx!.client, fx!.tables);
317
+ expect(await p.getConnectionInfo('alice')).toBeNull();
318
+ });
319
+
320
+ it('fanout to a peer that throws a non-Gone error rethrows (no silent swallow)', async () => {
321
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
322
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
323
+
324
+ const mgmt = new RecordingPostToConnection();
325
+ mgmt.markError('b', new Error('transient network failure'));
326
+
327
+ await expect(
328
+ handleDisconnect({
329
+ dynamo: fx!.client,
330
+ tables: fx!.tables,
331
+ connectionId: 'a',
332
+ managementApi: mgmt,
333
+ }),
334
+ ).rejects.toThrow('transient network failure');
335
+ });
336
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unit tests for `createDynamoDocumentClient`.
3
+ *
4
+ * The factory is intentionally tiny; these tests exercise every branch
5
+ * (with/without endpoint, with/without region, env fallback) without
6
+ * actually sending a command.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
11
+
12
+ describe('createDynamoDocumentClient', () => {
13
+ it('builds a client with an explicit endpoint', () => {
14
+ const c = createDynamoDocumentClient({ endpoint: 'http://localhost:8000' });
15
+ expect(c).toBeDefined();
16
+ expect(typeof c.send).toBe('function');
17
+ });
18
+
19
+ it('builds a client with explicit endpoint + region', () => {
20
+ const c = createDynamoDocumentClient({
21
+ endpoint: 'http://localhost:8000',
22
+ region: 'us-west-2',
23
+ });
24
+ expect(c).toBeDefined();
25
+ });
26
+
27
+ it('builds a client with region only (no endpoint)', () => {
28
+ const c = createDynamoDocumentClient({ region: 'eu-west-1' });
29
+ expect(c).toBeDefined();
30
+ });
31
+
32
+ it('falls back to AWS_REGION env when neither arg is supplied', () => {
33
+ const prev = process.env.AWS_REGION;
34
+ process.env.AWS_REGION = 'ap-southeast-2';
35
+ try {
36
+ const c = createDynamoDocumentClient({});
37
+ expect(c).toBeDefined();
38
+ } finally {
39
+ if (prev === undefined) {
40
+ Reflect.deleteProperty(process.env, 'AWS_REGION');
41
+ } else {
42
+ process.env.AWS_REGION = prev;
43
+ }
44
+ }
45
+ });
46
+
47
+ it('falls back to us-east-1 when nothing is supplied', () => {
48
+ const prev = process.env.AWS_REGION;
49
+ Reflect.deleteProperty(process.env, 'AWS_REGION');
50
+ try {
51
+ const c = createDynamoDocumentClient({});
52
+ expect(c).toBeDefined();
53
+ } finally {
54
+ if (prev !== undefined) process.env.AWS_REGION = prev;
55
+ }
56
+ });
57
+ });