serverless-ircd 0.3.0 → 0.5.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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 +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -31,6 +31,11 @@ interface Fx {
31
31
 
32
32
  let fx: Fx | null = null;
33
33
 
34
+ function requireFx(): Fx {
35
+ if (!fx) throw new Error('test fixture not initialized');
36
+ return fx;
37
+ }
38
+
34
39
  async function makeFx(): Promise<Fx> {
35
40
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
36
41
  const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
@@ -69,55 +74,57 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
69
74
  });
70
75
 
71
76
  it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
72
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 's3cret');
73
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
77
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
78
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
74
79
  expect(store).toBeDefined();
75
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' })).toEqual(
76
- {
77
- ok: true,
78
- account: 'alice',
79
- },
80
- );
80
+ expect(
81
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
82
+ ).toEqual({
83
+ ok: true,
84
+ account: 'alice',
85
+ });
81
86
  });
82
87
 
83
88
  it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
84
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
89
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
85
90
  expect(store).toBeUndefined();
86
91
  });
87
92
 
88
93
  it('putAccountCredential writes multiple distinct accounts', async () => {
89
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'a1');
90
- await putAccountCredential(fx!.client, fx!.tableName, 'bob', 'b2');
91
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
94
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
95
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
96
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
92
97
  expect(store).toBeDefined();
93
- expect(store!.size).toBe(2);
94
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
98
+ expect(store?.size).toBe(2);
99
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
100
+ true,
101
+ );
102
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
95
103
  true,
96
104
  );
97
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(true);
98
105
  });
99
106
 
100
107
  it('putAccountCredential overwrites an existing account (re-seed)', async () => {
101
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'old');
102
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'new');
103
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
108
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
109
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
110
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
104
111
  expect(store).toBeDefined();
105
- expect(store!.size).toBe(1);
106
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
112
+ expect(store?.size).toBe(1);
113
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
107
114
  false,
108
115
  );
109
- expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
116
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
110
117
  true,
111
118
  );
112
119
  });
113
120
 
114
121
  it('rejects a wrong password after round-trip', async () => {
115
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'right');
116
- const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
122
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
123
+ const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
117
124
  expect(store).toBeDefined();
118
- expect(
119
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok,
120
- ).toBe(false);
125
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
126
+ false,
127
+ );
121
128
  });
122
129
  });
123
130
 
@@ -131,19 +138,19 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
131
138
  });
132
139
 
133
140
  it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
134
- await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'table-secret');
141
+ await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
135
142
  const cfgWithSasl = {
136
143
  ...DEFAULT_SERVER_CONFIG,
137
144
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
138
145
  };
139
- const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
146
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
140
147
  expect(store).toBeDefined();
141
148
  // Table account verifies; config account does NOT (table is authoritative).
142
149
  expect(
143
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
150
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
144
151
  ).toBe(true);
145
152
  expect(
146
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
153
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
147
154
  ).toBe(false);
148
155
  });
149
156
 
@@ -152,20 +159,24 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
152
159
  ...DEFAULT_SERVER_CONFIG,
153
160
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
154
161
  };
155
- const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
162
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
156
163
  expect(store).toBeDefined();
157
164
  expect(
158
- store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
165
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
159
166
  ).toBe(true);
160
167
  });
161
168
 
162
169
  it('returns undefined when the table is empty and no config accounts are set', async () => {
163
- const store = await resolveAccountStore(fx!.client, fx!.tableName, DEFAULT_SERVER_CONFIG);
170
+ const store = await resolveAccountStore(
171
+ requireFx().client,
172
+ requireFx().tableName,
173
+ DEFAULT_SERVER_CONFIG,
174
+ );
164
175
  expect(store).toBeUndefined();
165
176
  });
166
177
 
167
178
  it('returns undefined when the table is empty and serverConfig is undefined', async () => {
168
- const store = await resolveAccountStore(fx!.client, fx!.tableName, undefined);
179
+ const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
169
180
  expect(store).toBeUndefined();
170
181
  });
171
182
  });
@@ -1,12 +1,16 @@
1
- import { mockClient } from 'aws-sdk-client-mock';
2
1
  import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
3
- import { type SaslPayload, DEFAULT_SERVER_CONFIG, type ParsedServerConfig } from '@serverless-ircd/irc-core';
2
+ import {
3
+ DEFAULT_SERVER_CONFIG,
4
+ type ParsedServerConfig,
5
+ type SaslPayload,
6
+ } from '@serverless-ircd/irc-core';
7
+ import { mockClient } from 'aws-sdk-client-mock';
4
8
  import { describe, expect, it } from 'vitest';
5
9
  import {
6
10
  DynamoAccountStore,
11
+ type HashedAccountCredential,
7
12
  bindAccountStore,
8
13
  hashAccountCredential,
9
- type HashedAccountCredential,
10
14
  putAccountCredential,
11
15
  resolveAccountStore,
12
16
  } from '../src/account-store';
@@ -28,7 +32,7 @@ describe('bindAccountStore', () => {
28
32
  it('returns an AccountStore that accepts valid PLAIN creds', () => {
29
33
  const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
30
34
  expect(store).toBeDefined();
31
- const out = store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
35
+ const out = store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
32
36
  expect(out).toEqual({ ok: true, account: 'alice' });
33
37
  });
34
38
 
@@ -41,8 +45,9 @@ describe('bindAccountStore', () => {
41
45
  });
42
46
 
43
47
  it('rejects wrong credentials', () => {
44
- const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]))!;
45
- expect(store.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
48
+ const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
49
+ expect(store).toBeDefined();
50
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
46
51
  false,
47
52
  );
48
53
  });
@@ -177,16 +182,14 @@ describe('resolveAccountStore', () => {
177
182
  const store = await resolveAccountStore(
178
183
  ddb as unknown as DynamoDBDocumentClient,
179
184
  'Accounts',
180
- fallback === undefined ? undefined : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
185
+ fallback === undefined
186
+ ? undefined
187
+ : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
181
188
  );
182
189
  expect(store).toBeDefined();
183
- expect(
184
- store!.verify('PLAIN', plainPayload('alice', 's3cret')).ok,
185
- ).toBe(true);
190
+ expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
186
191
  // Config account does NOT verify (table is authoritative).
187
- expect(
188
- store!.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok,
189
- ).toBe(false);
192
+ expect(store?.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok).toBe(false);
190
193
  });
191
194
 
192
195
  it('falls back to config-seeded store when the table is empty', async () => {
@@ -199,9 +202,7 @@ describe('resolveAccountStore', () => {
199
202
  configWith([{ username: 'bob', password: 'pw' }]),
200
203
  );
201
204
  expect(store).toBeDefined();
202
- expect(
203
- store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
204
- ).toBe(true);
205
+ expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
205
206
  });
206
207
 
207
208
  it('falls back to config-seeded store when the scan throws', async () => {
@@ -214,9 +215,7 @@ describe('resolveAccountStore', () => {
214
215
  configWith([{ username: 'bob', password: 'pw' }]),
215
216
  );
216
217
  expect(store).toBeDefined();
217
- expect(
218
- store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
219
- ).toBe(true);
218
+ expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
220
219
  });
221
220
 
222
221
  it('returns undefined when table is empty and no config accounts', async () => {
@@ -252,7 +251,7 @@ describe('putAccountCredential', () => {
252
251
 
253
252
  const calls = ddb.commandCalls(PutCommand);
254
253
  expect(calls).toHaveLength(1);
255
- const item = calls[0]!.args[0].input.Item as HashedAccountCredential;
254
+ const item = calls[0]?.args[0].input.Item as HashedAccountCredential;
256
255
  expect(item.account).toBe('alice');
257
256
  expect(item.algorithm).toBe('scrypt');
258
257
  expect(item.salt).toBe(entry.salt);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure unit tests for the AWS `$connect` admission policy
3
+ * (`src/admission.ts`). No DynamoDB — these exercise every branch of
4
+ * {@link decideConnectAdmission} directly so the per-IP branch is covered
5
+ * even though the AWS handler does not yet feed it a `perIp` count.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { decideConnectAdmission } from '../src/admission.js';
10
+
11
+ describe('decideConnectAdmission', () => {
12
+ it('admits when the total is below the cap and no per-IP limit is set', () => {
13
+ const outcome = decideConnectAdmission({ total: 0 }, { maxClients: 2 });
14
+ expect(outcome).toEqual({ admitted: true });
15
+ });
16
+
17
+ it('admits when the total is exactly one below the cap', () => {
18
+ expect(decideConnectAdmission({ total: 1 }, { maxClients: 2 })).toEqual({
19
+ admitted: true,
20
+ });
21
+ });
22
+
23
+ it('rejects with 429 once the total reaches the cap', () => {
24
+ expect(decideConnectAdmission({ total: 2 }, { maxClients: 2 })).toEqual({
25
+ admitted: false,
26
+ statusCode: 429,
27
+ reason: 'server full',
28
+ });
29
+ });
30
+
31
+ it('rejects with 429 when the total exceeds the cap', () => {
32
+ expect(decideConnectAdmission({ total: 9999 }, { maxClients: 10 })).toEqual({
33
+ admitted: false,
34
+ statusCode: 429,
35
+ reason: 'server full',
36
+ });
37
+ });
38
+
39
+ it('admits when a per-IP limit is set but no per-IP count is supplied', () => {
40
+ expect(
41
+ decideConnectAdmission({ total: 0 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
42
+ ).toEqual({ admitted: true });
43
+ });
44
+
45
+ it('admits when the per-IP count is below the per-IP limit', () => {
46
+ expect(
47
+ decideConnectAdmission({ total: 0, perIp: 2 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
48
+ ).toEqual({ admitted: true });
49
+ });
50
+
51
+ it('rejects with 429 once the per-IP count reaches the per-IP limit', () => {
52
+ expect(
53
+ decideConnectAdmission({ total: 0, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
54
+ ).toEqual({
55
+ admitted: false,
56
+ statusCode: 429,
57
+ reason: 'too many connections from this IP',
58
+ });
59
+ });
60
+
61
+ it('checks the global cap before the per-IP cap', () => {
62
+ expect(
63
+ decideConnectAdmission({ total: 10, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
64
+ ).toEqual({
65
+ admitted: false,
66
+ statusCode: 429,
67
+ reason: 'server full',
68
+ });
69
+ });
70
+ });
@@ -57,7 +57,8 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
57
57
  * unavailable so `describe.skipIf` short-circuits before any test runs.
58
58
  */
59
59
  export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
60
- name: 'aws',
60
+ name: 'aws+ws',
61
+ transport: 'ws',
61
62
  async create() {
62
63
  return new AwsHarness();
63
64
  },
@@ -344,6 +345,17 @@ class AwsClientHandle implements ClientHarness {
344
345
  await this.actor.receiveTextFrame(`${line}\r\n`);
345
346
  }
346
347
 
348
+ async feed(chunk: string): Promise<void> {
349
+ if (this.actor === undefined) {
350
+ throw new Error(`client ${this.id} has no bound actor`);
351
+ }
352
+ if (this.closed) return;
353
+ // Routes the raw chunk through the bound transport (the WS text-frame
354
+ // transport by default; a future aws+tcp factory via the NLB+Lambda
355
+ // byte-stream path would inject a TcpByteStreamTransport).
356
+ await this.actor.receive(chunk);
357
+ }
358
+
347
359
  async waitForLine(
348
360
  predicate: (line: string) => boolean,
349
361
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
10
10
  * DynamoDB Local cannot be booted (no Docker, no Java). When available,
11
- * every scenario MUST pass — that is the 040 acceptance criterion.
11
+ * every scenario MUST pass — that is the acceptance criterion.
12
12
  */
13
13
 
14
14
  import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
@@ -12,6 +12,7 @@ import {
12
12
  type Clock,
13
13
  type ConnectionState,
14
14
  type RawLine,
15
+ type ServerConfig,
15
16
  createConnection,
16
17
  } from '@serverless-ircd/irc-core';
17
18
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -652,3 +653,63 @@ function handlersSink(sink: string[], snapshot?: ConnectionState): AwsRuntimeHan
652
653
  snapshot: () => snapshot,
653
654
  };
654
655
  }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // reloadConfig (REHASH) — no DynamoDB access, so this group runs unconditionally.
659
+ // ---------------------------------------------------------------------------
660
+
661
+ describe('AwsRuntime — reloadConfig (REHASH)', () => {
662
+ const baseConfig: ServerConfig = {
663
+ serverName: 'irc.example.com',
664
+ networkName: 'ExampleNet',
665
+ maxChannelsPerUser: 30,
666
+ maxTargetsPerCommand: 10,
667
+ maxListEntries: 50,
668
+ nickLen: 30,
669
+ channelLen: 50,
670
+ topicLen: 390,
671
+ quitMessage: 'Client Quit',
672
+ };
673
+
674
+ function makeReloadRuntime(loader?: () => Promise<ServerConfig>): AwsRuntime {
675
+ return new AwsRuntime({
676
+ // reloadConfig never touches DynamoDB; a default client + empty tables
677
+ // suffice for the no-IO reload path.
678
+ dynamo: createDynamoDocumentClient({}),
679
+ tables: {} as TablesConfig,
680
+ connId: 'c1',
681
+ handlers: noopHandlers(),
682
+ managementApi: null,
683
+ ...(loader !== undefined ? { configLoader: loader } : {}),
684
+ });
685
+ }
686
+
687
+ it('re-invokes the bound loader and returns the fresh config', async () => {
688
+ let password = 'old';
689
+ const rt = makeReloadRuntime(async () => ({
690
+ ...baseConfig,
691
+ operCreds: [{ user: 'admin', password }],
692
+ }));
693
+
694
+ const first = await rt.reloadConfig();
695
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
696
+
697
+ password = 'rotated';
698
+ const second = await rt.reloadConfig();
699
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
700
+ });
701
+
702
+ it('propagates a loader rejection so the actor applies the graceful 382', async () => {
703
+ const rt = makeReloadRuntime(async () => {
704
+ throw new Error('Secrets Manager unreachable');
705
+ });
706
+
707
+ await expect(rt.reloadConfig()).rejects.toThrow('Secrets Manager unreachable');
708
+ });
709
+
710
+ it('rejects when no loader was bound at construction', async () => {
711
+ const rt = makeReloadRuntime();
712
+
713
+ await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
714
+ });
715
+ });
@@ -76,6 +76,26 @@ describe('buildLambdaConfigInput', () => {
76
76
  expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
77
77
  });
78
78
 
79
+ it('threads SERVER_VERSION into serverVersion', () => {
80
+ expect(buildLambdaConfigInput({ SERVER_VERSION: '1.2.3' }).serverVersion).toBe('1.2.3');
81
+ });
82
+
83
+ it('passes a non-numeric CREATED_AT through verbatim', () => {
84
+ expect(buildLambdaConfigInput({ CREATED_AT: '2024-06-01' }).createdAt).toBe('2024-06-01');
85
+ });
86
+
87
+ it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
88
+ expect(buildLambdaConfigInput({ CREATED_AT: '1700000000000' }).createdAt).toBe(
89
+ 1_700_000_000_000,
90
+ );
91
+ });
92
+
93
+ it('omits serverVersion/createdAt when their env vars are unset', () => {
94
+ const out = buildLambdaConfigInput({});
95
+ expect('serverVersion' in out).toBe(false);
96
+ expect('createdAt' in out).toBe(false);
97
+ });
98
+
79
99
  it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
80
100
  const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
81
101
  expect(out.saslAccounts).toEqual([
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Unit tests for `handleConnect` that exercise the DynamoDB plumbing
3
+ * branches the integration suite cannot reach cheaply — namely the
4
+ * paginated `Scan COUNT` loop (a real DynamoDB Local table fits in a
5
+ * single page) and the `Count ?? 0` defensive fallback. A hand-rolled
6
+ * stub client stands in for `DynamoDBDocumentClient`; the admission
7
+ * policy itself is covered by `admission.test.ts`.
8
+ */
9
+
10
+ import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
+ import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { handleConnect } from '../src/handlers/connect.js';
14
+ import type { MarshalledConnection } from '../src/serialize.js';
15
+ import type { TablesConfig } from '../src/tables.js';
16
+
17
+ const TABLES: TablesConfig = {
18
+ Connections: 'Connections',
19
+ ChannelMeta: 'ChannelMeta',
20
+ ChannelMembers: 'ChannelMembers',
21
+ Nicks: 'Nicks',
22
+ Accounts: 'Accounts',
23
+ };
24
+
25
+ interface Stub {
26
+ dynamo: DynamoDBDocumentClient;
27
+ puts: number;
28
+ /** Most recently PutCommand Item (undefined when no put happened). */
29
+ lastPutItem: Record<string, unknown> | undefined;
30
+ }
31
+
32
+ /** Builds a stub client that serves the supplied `Scan` pages in order. */
33
+ function makeStub(pages: Array<Record<string, unknown>>): Stub {
34
+ let scanIdx = 0;
35
+ const calls = { puts: 0 };
36
+ let lastPutItem: Record<string, unknown> | undefined;
37
+ const dynamo = {
38
+ send: async (cmd: unknown) => {
39
+ if (cmd instanceof ScanCommand) {
40
+ return pages[scanIdx++] ?? { Count: 0 };
41
+ }
42
+ if (cmd instanceof PutCommand) {
43
+ calls.puts++;
44
+ lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
45
+ return {};
46
+ }
47
+ throw new Error('unexpected command');
48
+ },
49
+ } as unknown as DynamoDBDocumentClient;
50
+ return {
51
+ dynamo,
52
+ get puts() {
53
+ return calls.puts;
54
+ },
55
+ get lastPutItem() {
56
+ return lastPutItem;
57
+ },
58
+ };
59
+ }
60
+
61
+ describe('handleConnect admission plumbing', () => {
62
+ it('sums Scan COUNT across pages and admits (writes the row) when below the cap', async () => {
63
+ const stub = makeStub([{ Count: 1, LastEvaluatedKey: { connectionId: 'page-1' } }, {}]);
64
+ const outcome = await handleConnect({
65
+ dynamo: stub.dynamo,
66
+ tables: TABLES,
67
+ connectionId: 'c-paged',
68
+ now: 42,
69
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
70
+ });
71
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
72
+ expect(stub.puts).toBe(1);
73
+ });
74
+
75
+ it('does not write a row when the count reaches the cap', async () => {
76
+ const stub = makeStub([{ Count: 5 }]);
77
+ const outcome = await handleConnect({
78
+ dynamo: stub.dynamo,
79
+ tables: TABLES,
80
+ connectionId: 'c-full',
81
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
82
+ });
83
+ expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
84
+ expect(stub.puts).toBe(0);
85
+ });
86
+ });
87
+
88
+ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
89
+ it('selects binary.ircv3.net, echoes it, and persists a spec-binary mode row', async () => {
90
+ const stub = makeStub([{ Count: 0 }]);
91
+ const outcome = await handleConnect({
92
+ dynamo: stub.dynamo,
93
+ tables: TABLES,
94
+ connectionId: 'c-binary',
95
+ now: 42,
96
+ secWebSocketProtocol: 'binary.ircv3.net',
97
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
98
+ });
99
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'binary.ircv3.net' });
100
+ expect(stub.puts).toBe(1);
101
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
102
+ expect(item?.wsMode).toBe('spec-binary');
103
+ });
104
+
105
+ it('selects text.ircv3.net and persists a spec-text mode row', async () => {
106
+ const stub = makeStub([{ Count: 0 }]);
107
+ const outcome = await handleConnect({
108
+ dynamo: stub.dynamo,
109
+ tables: TABLES,
110
+ connectionId: 'c-text',
111
+ now: 42,
112
+ secWebSocketProtocol: 'text.ircv3.net',
113
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
114
+ });
115
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
116
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
117
+ expect(item?.wsMode).toBe('spec-text');
118
+ });
119
+
120
+ it('honours client-preference order when both subprotocols are offered', async () => {
121
+ const stub = makeStub([{ Count: 0 }]);
122
+ const outcome = await handleConnect({
123
+ dynamo: stub.dynamo,
124
+ tables: TABLES,
125
+ connectionId: 'c-pref',
126
+ now: 42,
127
+ secWebSocketProtocol: 'text.ircv3.net, binary.ircv3.net',
128
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
129
+ });
130
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
131
+ });
132
+
133
+ it('falls back to legacy (null subprotocol, no mode column) when no offer is present', async () => {
134
+ const stub = makeStub([{ Count: 0 }]);
135
+ const outcome = await handleConnect({
136
+ dynamo: stub.dynamo,
137
+ tables: TABLES,
138
+ connectionId: 'c-legacy',
139
+ now: 42,
140
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
141
+ });
142
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
143
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
144
+ expect(item?.wsMode).toBeUndefined();
145
+ });
146
+
147
+ it('falls back to legacy when only unsupported subprotocols are offered', async () => {
148
+ const stub = makeStub([{ Count: 0 }]);
149
+ const outcome = await handleConnect({
150
+ dynamo: stub.dynamo,
151
+ tables: TABLES,
152
+ connectionId: 'c-unsupported',
153
+ now: 42,
154
+ secWebSocketProtocol: 'chat, superchat.v2',
155
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
156
+ });
157
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
158
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
159
+ expect(item?.wsMode).toBeUndefined();
160
+ });
161
+
162
+ it('does not negotiate a subprotocol when the admission gate rejects', async () => {
163
+ const stub = makeStub([{ Count: 5 }]);
164
+ const outcome = await handleConnect({
165
+ dynamo: stub.dynamo,
166
+ tables: TABLES,
167
+ connectionId: 'c-full',
168
+ secWebSocketProtocol: 'binary.ircv3.net',
169
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
170
+ });
171
+ expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
172
+ expect(stub.puts).toBe(0);
173
+ });
174
+ });