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,654 @@
1
+ /**
2
+ * Unit tests for `AwsRuntime` — the DynamoDB-backed `IrcRuntime`.
3
+ *
4
+ * Uses real DynamoDB Local (no mocks) so the conditional writes, GSI
5
+ * semantics, and `KeyConditionExpression`s are exercised end-to-end.
6
+ * The harness in `aws-harness.ts` builds on these same primitives.
7
+ */
8
+
9
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
+ import {
11
+ type ChannelDelta,
12
+ type Clock,
13
+ type ConnectionState,
14
+ type RawLine,
15
+ createConnection,
16
+ } from '@serverless-ircd/irc-core';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import { AwsRuntime } from '../src/aws-runtime.js';
19
+ import type { AwsRuntimeHandlers, PostToConnection } from '../src/aws-runtime.js';
20
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
21
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
22
+ import type { TablesConfig } from '../src/tables.js';
23
+
24
+ const available = process.env.DDB_AVAILABLE === '1';
25
+ const endpoint = process.env.DYNAMO_ENDPOINT;
26
+
27
+ interface Fixture {
28
+ tables: TablesConfig;
29
+ client: ReturnType<typeof createDynamoDocumentClient>;
30
+ cleanup: () => Promise<void>;
31
+ }
32
+
33
+ let fx: Fixture;
34
+
35
+ async function makeFixture(): Promise<Fixture> {
36
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
37
+ const prefix = `t${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
38
+ const tables = Object.fromEntries(
39
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
40
+ ) as TablesConfig;
41
+ const client = createDynamoDocumentClient({ endpoint });
42
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
43
+ const pkName = props.partitionKey?.name;
44
+ const skName = props.sortKey?.name;
45
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
46
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
47
+ { AttributeName: pkName, AttributeType: 'S' },
48
+ ];
49
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
50
+ { AttributeName: pkName, KeyType: 'HASH' },
51
+ ];
52
+ if (skName !== undefined) {
53
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
54
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
55
+ }
56
+ await client.send(
57
+ new CreateTableCommand({
58
+ TableName: `${prefix}${logical}`,
59
+ AttributeDefinitions: attributeDefinitions,
60
+ KeySchema: keySchema,
61
+ BillingMode: 'PAY_PER_REQUEST',
62
+ }),
63
+ );
64
+ }
65
+ return {
66
+ tables,
67
+ client,
68
+ cleanup: async () => {
69
+ for (const logical of Object.keys(TABLE_DEFS)) {
70
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ function makeRuntime(
77
+ connId: string,
78
+ handlers: AwsRuntimeHandlers,
79
+ clockFn: () => number = () => 1_000,
80
+ managementApi: PostToConnection | null = null,
81
+ ): AwsRuntime {
82
+ const clock: Clock = { now: clockFn };
83
+ return new AwsRuntime({
84
+ dynamo: fx.client,
85
+ tables: fx.tables,
86
+ connId,
87
+ handlers,
88
+ managementApi,
89
+ clock,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
95
+ * calls to the registered handler for `ConnectionId`.
96
+ */
97
+ class LocalPostToConnection implements PostToConnection {
98
+ private readonly sinks = new Map<string, (data: string) => void>();
99
+ register(connId: string, sink: (data: string) => void): void {
100
+ this.sinks.set(connId, sink);
101
+ }
102
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
103
+ const sink = this.sinks.get(input.ConnectionId);
104
+ if (sink !== undefined) sink(input.Data);
105
+ return {};
106
+ }
107
+ }
108
+
109
+ describe.skipIf(!available)('AwsRuntime', () => {
110
+ beforeEach(async () => {
111
+ fx = await makeFixture();
112
+ });
113
+ afterEach(async () => {
114
+ if (fx) await fx.cleanup();
115
+ });
116
+
117
+ // -------------------------------------------------------------------------
118
+ // Nick registry
119
+ // -------------------------------------------------------------------------
120
+
121
+ describe('nick registry', () => {
122
+ it('reserveNick succeeds for a fresh nick', async () => {
123
+ const r = await makeRuntime('c1', noopHandlers()).reserveNick('Alice', 'c1');
124
+ expect(r).toEqual({ ok: true });
125
+ });
126
+
127
+ it('reserveNick fails when the nick is already owned by another conn', async () => {
128
+ const rt = makeRuntime('c1', noopHandlers());
129
+ await rt.reserveNick('Alice', 'c1');
130
+ const r = await rt.reserveNick('Alice', 'c2');
131
+ expect(r).toEqual({ ok: false });
132
+ });
133
+
134
+ it('reserveNick is idempotent for the same owner', async () => {
135
+ const rt = makeRuntime('c1', noopHandlers());
136
+ await rt.reserveNick('Alice', 'c1');
137
+ const r = await rt.reserveNick('Alice', 'c1');
138
+ expect(r).toEqual({ ok: true });
139
+ });
140
+
141
+ it('lookupNick returns the owning conn id (case-insensitive)', async () => {
142
+ const rt = makeRuntime('c1', noopHandlers());
143
+ await rt.reserveNick('Alice', 'c1');
144
+ expect(await rt.lookupNick('ALICE')).toBe('c1');
145
+ expect(await rt.lookupNick('alice')).toBe('c1');
146
+ });
147
+
148
+ it('lookupNick returns null for an unknown nick', async () => {
149
+ const rt = makeRuntime('c1', noopHandlers());
150
+ expect(await rt.lookupNick('nobody')).toBeNull();
151
+ });
152
+
153
+ it('releaseNick deletes the row', async () => {
154
+ const rt = makeRuntime('c1', noopHandlers());
155
+ await rt.reserveNick('Alice', 'c1');
156
+ await rt.releaseNick('Alice');
157
+ expect(await rt.lookupNick('Alice')).toBeNull();
158
+ });
159
+
160
+ it('releaseNick is idempotent on an unknown nick', async () => {
161
+ const rt = makeRuntime('c1', noopHandlers());
162
+ await rt.releaseNick('never-reserved');
163
+ await rt.reserveNick('Bob', 'c1');
164
+ await rt.releaseNick('Bob');
165
+ await rt.releaseNick('Bob');
166
+ });
167
+
168
+ it('changeNick moves the registration to the new nick', async () => {
169
+ const rt = makeRuntime('c1', noopHandlers());
170
+ await rt.reserveNick('Alice', 'c1');
171
+ const ok = await rt.changeNick('c1', 'Alice', 'Alice2');
172
+ expect(ok).toBe(true);
173
+ expect(await rt.lookupNick('Alice')).toBeNull();
174
+ expect(await rt.lookupNick('Alice2')).toBe('c1');
175
+ });
176
+
177
+ it('changeNick fails when the target is taken', async () => {
178
+ const rt = makeRuntime('c1', noopHandlers());
179
+ await rt.reserveNick('Alice', 'c1');
180
+ await rt.reserveNick('Bob', 'c2');
181
+ const ok = await rt.changeNick('c1', 'Alice', 'Bob');
182
+ expect(ok).toBe(false);
183
+ expect(await rt.lookupNick('Alice')).toBe('c1');
184
+ });
185
+ });
186
+
187
+ // -------------------------------------------------------------------------
188
+ // Connection info
189
+ // -------------------------------------------------------------------------
190
+
191
+ describe('connection info', () => {
192
+ it('getConnectionInfo returns null for an unknown connection', async () => {
193
+ const rt = makeRuntime('self', noopHandlers());
194
+ expect(await rt.getConnectionInfo('unknown')).toBeNull();
195
+ });
196
+
197
+ it('getConnection returns null for an unknown connection', async () => {
198
+ const rt = makeRuntime('self', noopHandlers());
199
+ expect(await rt.getConnection('unknown')).toBeNull();
200
+ });
201
+
202
+ it('getConnectionInfo reads a persisted connection row', async () => {
203
+ const rt = makeRuntime('self', noopHandlers());
204
+ const state = createConnection({ id: 'c1', connectedSince: 100 });
205
+ state.nick = 'Alice';
206
+ state.caps.add('echo-message');
207
+ await rt.persistConnectionState(state);
208
+ const info = await rt.getConnectionInfo('c1');
209
+ expect(info?.id).toBe('c1');
210
+ expect(info?.nick).toBe('Alice');
211
+ expect(info?.caps.has('echo-message')).toBe(true);
212
+ });
213
+
214
+ it('getConnection reads a persisted connection row (full state)', async () => {
215
+ const rt = makeRuntime('self', noopHandlers());
216
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
217
+ state.nick = 'Alice';
218
+ state.user = 'alice';
219
+ await rt.persistConnectionState(state);
220
+ const got = await rt.getConnection('c1');
221
+ expect(got?.id).toBe('c1');
222
+ expect(got?.nick).toBe('Alice');
223
+ expect(got?.user).toBe('alice');
224
+ });
225
+
226
+ it('getConnection for the bound connection returns the in-memory snapshot', async () => {
227
+ const state = createConnection({ id: 'self', connectedSince: 0 });
228
+ state.nick = 'Me';
229
+ const rt = makeRuntime('self', {
230
+ send: () => {},
231
+ disconnect: () => {},
232
+ snapshot: () => state,
233
+ });
234
+ const got = await rt.getConnection('self');
235
+ expect(got).toBe(state);
236
+ });
237
+ });
238
+
239
+ // -------------------------------------------------------------------------
240
+ // sendToNick
241
+ // -------------------------------------------------------------------------
242
+
243
+ describe('sendToNick', () => {
244
+ it('routes lines to the owning connection', async () => {
245
+ const received: string[] = [];
246
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
247
+ state.nick = 'Alice';
248
+ const rt = makeRuntime('c1', handlersSink(received, state));
249
+ await rt.persistConnectionState(state);
250
+ await rt.reserveNick('Alice', 'c1');
251
+ await rt.sendToNick('Alice', 'sender', [{ text: ':hi Alice' }]);
252
+ expect(received).toEqual([':hi Alice']);
253
+ });
254
+
255
+ it('falls back to notFoundLines when the nick is unknown', async () => {
256
+ const received: string[] = [];
257
+ const state = createConnection({ id: 'self', connectedSince: 0 });
258
+ const rt = makeRuntime('self', handlersSink(received, state));
259
+ await rt.sendToNick(
260
+ 'ghost',
261
+ 'self',
262
+ [{ text: ':hello?' }],
263
+ [{ text: ':ghost 401 no such nick' }],
264
+ );
265
+ expect(received).toEqual([':ghost 401 no such nick']);
266
+ });
267
+
268
+ it('omits the fallback when notFoundLines is missing', async () => {
269
+ const received: string[] = [];
270
+ const state = createConnection({ id: 'self', connectedSince: 0 });
271
+ const rt = makeRuntime('self', handlersSink(received, state));
272
+ await rt.sendToNick('ghost', 'self', [{ text: ':hello?' }]);
273
+ expect(received).toEqual([]);
274
+ });
275
+ });
276
+
277
+ // -------------------------------------------------------------------------
278
+ // applyChannelDelta + getChannelSnapshot
279
+ // -------------------------------------------------------------------------
280
+
281
+ describe('channel deltas', () => {
282
+ it('getChannelSnapshot returns null for an unknown channel', async () => {
283
+ const rt = makeRuntime('self', noopHandlers());
284
+ expect(await rt.getChannelSnapshot('#nope')).toBeNull();
285
+ });
286
+
287
+ it('adds memberships and reads them back via getChannelSnapshot', async () => {
288
+ const rt = makeRuntime('self', noopHandlers());
289
+ const delta: ChannelDelta = {
290
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice', op: true }],
291
+ };
292
+ await rt.applyChannelDelta('#foo', delta);
293
+ const snap = await rt.getChannelSnapshot('#foo');
294
+ expect(snap?.nameLower).toBe('#foo');
295
+ expect(snap?.members.get('c1')).toEqual({
296
+ conn: 'c1',
297
+ nick: 'Alice',
298
+ op: true,
299
+ voice: false,
300
+ });
301
+ });
302
+
303
+ it('removes memberships via delta', async () => {
304
+ const rt = makeRuntime('self', noopHandlers());
305
+ await rt.applyChannelDelta('#foo', {
306
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
307
+ });
308
+ await rt.applyChannelDelta('#foo', {
309
+ memberships: [{ type: 'remove', conn: 'c1' }],
310
+ });
311
+ const snap = await rt.getChannelSnapshot('#foo');
312
+ expect(snap?.members.has('c1')).toBe(false);
313
+ });
314
+
315
+ it('persists topic and modes in ChannelMeta', async () => {
316
+ const rt = makeRuntime('self', noopHandlers());
317
+ await rt.applyChannelDelta('#foo', {
318
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice', op: true }],
319
+ topic: { text: 'welcome', setter: 'alice', setAt: 100 },
320
+ modeChanges: [{ mode: 'topicLock', set: true }],
321
+ });
322
+ const snap = await rt.getChannelSnapshot('#foo');
323
+ expect(snap?.topic?.text).toBe('welcome');
324
+ expect(snap?.modes.topicLock).toBe(true);
325
+ });
326
+
327
+ it('getChannelSnapshot rehydrates members as a Map', async () => {
328
+ const rt = makeRuntime('self', noopHandlers());
329
+ await rt.applyChannelDelta('#foo', {
330
+ memberships: [
331
+ { type: 'add', conn: 'c1', nick: 'Alice', op: true },
332
+ { type: 'add', conn: 'c2', nick: 'Bob', voice: true },
333
+ ],
334
+ });
335
+ const snap = await rt.getChannelSnapshot('#foo');
336
+ expect(snap?.members).toBeInstanceOf(Map);
337
+ expect(snap?.members.size).toBe(2);
338
+ });
339
+ });
340
+
341
+ // -------------------------------------------------------------------------
342
+ // send / broadcast / disconnect
343
+ // -------------------------------------------------------------------------
344
+
345
+ describe('transport', () => {
346
+ it('send to the bound connection routes through handlers.send', async () => {
347
+ const received: string[] = [];
348
+ const rt = makeRuntime('self', handlersSink(received));
349
+ await rt.send('self', [{ text: 'PING :tok' }]);
350
+ expect(received).toEqual(['PING :tok']);
351
+ });
352
+
353
+ it('broadcast fans out to every member except the caller', async () => {
354
+ const receivedA: string[] = [];
355
+ const receivedB: string[] = [];
356
+ const stateA = createConnection({ id: 'c-a', connectedSince: 0 });
357
+ const stateB = createConnection({ id: 'c-b', connectedSince: 0 });
358
+
359
+ const mgmt = new LocalPostToConnection();
360
+ mgmt.register('c-a', (data) => {
361
+ for (const line of data.split('\r\n')) {
362
+ if (line.length > 0) receivedA.push(line);
363
+ }
364
+ });
365
+ mgmt.register('c-b', (data) => {
366
+ for (const line of data.split('\r\n')) {
367
+ if (line.length > 0) receivedB.push(line);
368
+ }
369
+ });
370
+
371
+ const rtA = makeRuntime('c-a', handlersSink(receivedA, stateA), undefined, mgmt);
372
+ const rtB = makeRuntime('c-b', handlersSink(receivedB, stateB), undefined, mgmt);
373
+
374
+ await rtA.persistConnectionState(stateA);
375
+ await rtB.persistConnectionState(stateB);
376
+ await rtA.applyChannelDelta('#room', {
377
+ memberships: [
378
+ { type: 'add', conn: 'c-a', nick: 'Alice' },
379
+ { type: 'add', conn: 'c-b', nick: 'Bob' },
380
+ ],
381
+ });
382
+ await rtA.broadcast('#room', [{ text: ':hello' }], 'c-a');
383
+ expect(receivedA).toEqual([]);
384
+ expect(receivedB).toEqual([':hello']);
385
+ });
386
+
387
+ it('disconnect on the bound connection calls handlers.disconnect', async () => {
388
+ let disconnected = false;
389
+ const rt = makeRuntime('self', {
390
+ send: () => {},
391
+ disconnect: () => {
392
+ disconnected = true;
393
+ },
394
+ snapshot: () => undefined,
395
+ });
396
+ await rt.disconnect('self', 'reason');
397
+ expect(disconnected).toBe(true);
398
+ });
399
+ });
400
+
401
+ // -------------------------------------------------------------------------
402
+ // listChannels + getChannelConnections
403
+ // -------------------------------------------------------------------------
404
+
405
+ describe('channel lookups', () => {
406
+ it('listChannels returns every channel that has a meta row', async () => {
407
+ const rt = makeRuntime('self', noopHandlers());
408
+ await rt.applyChannelDelta('#a', {
409
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
410
+ });
411
+ await rt.applyChannelDelta('#b', {
412
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
413
+ });
414
+ const list = await rt.listChannels();
415
+ const names = list.map((c) => c.nameLower).sort();
416
+ expect(names).toEqual(['#a', '#b']);
417
+ });
418
+
419
+ it('getChannelConnections returns full state for every member', async () => {
420
+ const rt = makeRuntime('self', noopHandlers());
421
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
422
+ state.nick = 'Alice';
423
+ await rt.persistConnectionState(state);
424
+ await rt.applyChannelDelta('#a', {
425
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
426
+ });
427
+ const map = await rt.getChannelConnections('#a');
428
+ expect(map.size).toBe(1);
429
+ expect(map.get('c1')?.nick).toBe('Alice');
430
+ });
431
+ });
432
+
433
+ // -------------------------------------------------------------------------
434
+ // Mode-change coverage (key / limit / topic / banMasks)
435
+ // -------------------------------------------------------------------------
436
+
437
+ describe('channel modes', () => {
438
+ it('set + unset the key mode via applyChannelDelta', async () => {
439
+ const rt = makeRuntime('self', noopHandlers());
440
+ await rt.applyChannelDelta('#m', {
441
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
442
+ modeChanges: [{ mode: 'key', set: true, arg: 'sekret' }],
443
+ });
444
+ let snap = await rt.getChannelSnapshot('#m');
445
+ expect(snap?.modes.key).toBe('sekret');
446
+ await rt.applyChannelDelta('#m', {
447
+ modeChanges: [{ mode: 'key', set: false }],
448
+ });
449
+ snap = await rt.getChannelSnapshot('#m');
450
+ expect(snap?.modes.key).toBeUndefined();
451
+ });
452
+
453
+ it('set + unset the limit mode via applyChannelDelta', async () => {
454
+ const rt = makeRuntime('self', noopHandlers());
455
+ await rt.applyChannelDelta('#m', {
456
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
457
+ modeChanges: [{ mode: 'limit', set: true, arg: 5 }],
458
+ });
459
+ let snap = await rt.getChannelSnapshot('#m');
460
+ expect(snap?.modes.limit).toBe(5);
461
+ await rt.applyChannelDelta('#m', {
462
+ modeChanges: [{ mode: 'limit', set: false }],
463
+ });
464
+ snap = await rt.getChannelSnapshot('#m');
465
+ expect(snap?.modes.limit).toBeUndefined();
466
+ });
467
+
468
+ it('toggles every boolean mode (inviteOnly/topicLock/noExternal/moderated/secret/private)', async () => {
469
+ const rt = makeRuntime('self', noopHandlers());
470
+ await rt.applyChannelDelta('#m', {
471
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
472
+ modeChanges: [
473
+ { mode: 'inviteOnly', set: true },
474
+ { mode: 'topicLock', set: true },
475
+ { mode: 'noExternal', set: true },
476
+ { mode: 'moderated', set: true },
477
+ { mode: 'secret', set: true },
478
+ { mode: 'private', set: true },
479
+ ],
480
+ });
481
+ const snap = await rt.getChannelSnapshot('#m');
482
+ expect(snap?.modes.inviteOnly).toBe(true);
483
+ expect(snap?.modes.topicLock).toBe(true);
484
+ expect(snap?.modes.noExternal).toBe(true);
485
+ expect(snap?.modes.moderated).toBe(true);
486
+ expect(snap?.modes.secret).toBe(true);
487
+ expect(snap?.modes.private).toBe(true);
488
+ });
489
+
490
+ it('ignores a key set without an arg', async () => {
491
+ const rt = makeRuntime('self', noopHandlers());
492
+ await rt.applyChannelDelta('#m', {
493
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
494
+ modeChanges: [{ mode: 'key', set: true }],
495
+ });
496
+ const snap = await rt.getChannelSnapshot('#m');
497
+ expect(snap?.modes.key).toBeUndefined();
498
+ });
499
+
500
+ it('ignores a limit set with a non-numeric arg', async () => {
501
+ const rt = makeRuntime('self', noopHandlers());
502
+ await rt.applyChannelDelta('#m', {
503
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
504
+ modeChanges: [{ mode: 'limit', set: true, arg: 'nope' as unknown as number }],
505
+ });
506
+ const snap = await rt.getChannelSnapshot('#m');
507
+ expect(snap?.modes.limit).toBeUndefined();
508
+ });
509
+
510
+ it('no-op applyChannelDelta (undefined modeChanges) leaves modes untouched', async () => {
511
+ const rt = makeRuntime('self', noopHandlers());
512
+ await rt.applyChannelDelta('#m', {
513
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
514
+ });
515
+ await rt.applyChannelDelta('#m', {});
516
+ const snap = await rt.getChannelSnapshot('#m');
517
+ expect(snap?.modes.inviteOnly).toBe(false);
518
+ });
519
+ });
520
+
521
+ // -------------------------------------------------------------------------
522
+ // Ban masks
523
+ // -------------------------------------------------------------------------
524
+
525
+ describe('ban masks', () => {
526
+ it('add and remove banMasks via applyChannelDelta', async () => {
527
+ const rt = makeRuntime('self', noopHandlers());
528
+ await rt.applyChannelDelta('#b', {
529
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
530
+ banMaskChanges: [{ type: 'add', mask: '*!bad@host' }],
531
+ });
532
+ let snap = await rt.getChannelSnapshot('#b');
533
+ expect([...(snap?.banMasks ?? [])]).toEqual(['*!bad@host']);
534
+ await rt.applyChannelDelta('#b', {
535
+ banMaskChanges: [{ type: 'remove', mask: '*!bad@host' }],
536
+ });
537
+ snap = await rt.getChannelSnapshot('#b');
538
+ expect([...(snap?.banMasks ?? [])]).toEqual([]);
539
+ });
540
+ });
541
+
542
+ // -------------------------------------------------------------------------
543
+ // Topic unset
544
+ // -------------------------------------------------------------------------
545
+
546
+ describe('topic', () => {
547
+ it('clears the topic when the delta sends topic=null', async () => {
548
+ const rt = makeRuntime('self', noopHandlers());
549
+ await rt.applyChannelDelta('#t', {
550
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
551
+ topic: { text: 'hi', setter: 'alice', setAt: 100 },
552
+ });
553
+ let snap = await rt.getChannelSnapshot('#t');
554
+ expect(snap?.topic?.text).toBe('hi');
555
+ await rt.applyChannelDelta('#t', { topic: null });
556
+ snap = await rt.getChannelSnapshot('#t');
557
+ expect(snap?.topic).toBeUndefined();
558
+ });
559
+ });
560
+
561
+ // -------------------------------------------------------------------------
562
+ // Error-path coverage (releaseNick + ensureMetaRow rethrow branches)
563
+ // -------------------------------------------------------------------------
564
+
565
+ describe('error propagation', () => {
566
+ it('releaseNick rethrows non-ResourceNotFound errors', async () => {
567
+ // Inject a fake client whose `send` rejects with a generic error.
568
+ const fakeClient = {
569
+ send: async () => {
570
+ const err = new Error('network down') as Error & { name: string };
571
+ err.name = 'SomeOtherException';
572
+ throw err;
573
+ },
574
+ } as unknown as import('@aws-sdk/lib-dynamodb').DynamoDBDocumentClient;
575
+ const rt = new AwsRuntime({
576
+ dynamo: fakeClient,
577
+ tables: fx.tables,
578
+ connId: 'self',
579
+ handlers: noopHandlers(),
580
+ managementApi: null,
581
+ });
582
+ await expect(rt.releaseNick('Alice')).rejects.toThrow('network down');
583
+ });
584
+
585
+ it('releaseNick swallows ResourceNotFound errors (idempotent)', async () => {
586
+ const fakeClient = {
587
+ send: async () => {
588
+ const err = new Error('not found') as Error & { name: string };
589
+ err.name = 'ResourceNotFoundException';
590
+ throw err;
591
+ },
592
+ } as unknown as import('@aws-sdk/lib-dynamodb').DynamoDBDocumentClient;
593
+ const rt = new AwsRuntime({
594
+ dynamo: fakeClient,
595
+ tables: fx.tables,
596
+ connId: 'self',
597
+ handlers: noopHandlers(),
598
+ managementApi: null,
599
+ });
600
+ await expect(rt.releaseNick('Alice')).resolves.toBeUndefined();
601
+ });
602
+
603
+ it('applyChannelDelta rethrows non-conditional errors from ensureMetaRow', async () => {
604
+ // Track call count so we only fail on the first Put (ChannelMeta).
605
+ let firstPut = true;
606
+ const fakeClient = {
607
+ send: async (cmd: unknown) => {
608
+ const input = (cmd as { input?: { TableName?: string } }).input;
609
+ if (input?.TableName === fx.tables.ChannelMeta && firstPut) {
610
+ firstPut = false;
611
+ const err = new Error('throttled') as Error & { name: string };
612
+ err.name = 'ProvisionedThroughputExceededException';
613
+ throw err;
614
+ }
615
+ throw new Error('unexpected send');
616
+ },
617
+ } as unknown as import('@aws-sdk/lib-dynamodb').DynamoDBDocumentClient;
618
+ const rt = new AwsRuntime({
619
+ dynamo: fakeClient,
620
+ tables: fx.tables,
621
+ connId: 'self',
622
+ handlers: noopHandlers(),
623
+ managementApi: null,
624
+ });
625
+ await expect(
626
+ rt.applyChannelDelta('#x', {
627
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' }],
628
+ }),
629
+ ).rejects.toThrow('throttled');
630
+ });
631
+ });
632
+ });
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Helpers
636
+ // ---------------------------------------------------------------------------
637
+
638
+ function noopHandlers(): AwsRuntimeHandlers {
639
+ return {
640
+ send: () => {},
641
+ disconnect: () => {},
642
+ snapshot: () => undefined,
643
+ };
644
+ }
645
+
646
+ function handlersSink(sink: string[], snapshot?: ConnectionState): AwsRuntimeHandlers {
647
+ return {
648
+ send: (lines: RawLine[]) => {
649
+ for (const l of lines) sink.push(l.text);
650
+ },
651
+ disconnect: () => {},
652
+ snapshot: () => snapshot,
653
+ };
654
+ }