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,571 @@
1
+ /**
2
+ * EventBridge idle / PING sweep — a Lambda on a fixed EventBridge
3
+ * schedule (rate(1 minute)) that scans `Connections` for rows past
4
+ * the PING threshold and either posts a `PING` or, if the PONG
5
+ * window has also elapsed, tears the connection down via
6
+ * {@link cleanupConnection}.
7
+ *
8
+ * Catches the case where a client has gone silent but APIGW has not
9
+ * yet observed a TCP-level close (so no `$disconnect` event). Mirrors
10
+ * the Cloudflare adapter's `ConnectionDO.alarm()` PING/PONG logic,
11
+ * adapted to the batch-scan Lambda model AWS requires (one Lambda
12
+ * invocation inspects every connection; per-connection EventBridge
13
+ * one-shot schedules would scale to N scheduler entries per active
14
+ * user and were ruled out for v1).
15
+ *
16
+ * Gated on `process.env.DDB_AVAILABLE` (real DynamoDB Local). A
17
+ * second `describe` block covers scan-edge-case branches against a
18
+ * fake document client so the suite is green even without Docker.
19
+ */
20
+
21
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
22
+ import {
23
+ type DynamoDBDocumentClient,
24
+ GetCommand,
25
+ PutCommand,
26
+ ScanCommand,
27
+ } from '@aws-sdk/lib-dynamodb';
28
+ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
29
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
30
+ import { cleanupConnection } from '../src/aws-runtime.js';
31
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
32
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
33
+ import {
34
+ DEFAULT_PING_INTERVAL_MS,
35
+ DEFAULT_PONG_TIMEOUT_MS,
36
+ type PingCheckParams,
37
+ type PingCheckResult,
38
+ handlePingCheck,
39
+ } from '../src/handlers/ping-checker.js';
40
+ import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
41
+ import type { TablesConfig } from '../src/tables.js';
42
+
43
+ const available = process.env.DDB_AVAILABLE === '1';
44
+ const endpoint = process.env.DYNAMO_ENDPOINT;
45
+
46
+ interface Fixture {
47
+ tables: TablesConfig;
48
+ client: ReturnType<typeof createDynamoDocumentClient>;
49
+ mgmt: RecordingPostToConnection;
50
+ cleanup: () => Promise<void>;
51
+ }
52
+
53
+ let fx: Fixture | null;
54
+
55
+ async function makeFixture(): Promise<Fixture> {
56
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
57
+ const prefix = `p${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
58
+ const tables = Object.fromEntries(
59
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
60
+ ) as TablesConfig;
61
+ const client = createDynamoDocumentClient({ endpoint });
62
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
63
+ const pkName = props.partitionKey?.name;
64
+ const skName = props.sortKey?.name;
65
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
66
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
67
+ { AttributeName: pkName, AttributeType: 'S' },
68
+ ];
69
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
70
+ { AttributeName: pkName, KeyType: 'HASH' },
71
+ ];
72
+ if (skName !== undefined) {
73
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
74
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
75
+ }
76
+ await client.send(
77
+ new CreateTableCommand({
78
+ TableName: `${prefix}${logical}`,
79
+ AttributeDefinitions: attributeDefinitions,
80
+ KeySchema: keySchema,
81
+ BillingMode: 'PAY_PER_REQUEST',
82
+ }),
83
+ );
84
+ }
85
+ return {
86
+ tables,
87
+ client,
88
+ mgmt: new RecordingPostToConnection(),
89
+ cleanup: async () => {
90
+ for (const logical of Object.keys(TABLE_DEFS)) {
91
+ try {
92
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
93
+ } catch {
94
+ // best-effort
95
+ }
96
+ }
97
+ },
98
+ };
99
+ }
100
+
101
+ function params(overrides: Partial<PingCheckParams> = {}): PingCheckParams {
102
+ return {
103
+ dynamo: fx!.client,
104
+ tables: fx!.tables,
105
+ managementApi: fx!.mgmt,
106
+ ...overrides,
107
+ };
108
+ }
109
+
110
+ /** Seeds a connection row with the supplied idleSince + optional nick + memberships. */
111
+ async function seedConnection(opts: {
112
+ connId: string;
113
+ idleSince: number;
114
+ nick?: string;
115
+ channels?: ChanName[];
116
+ }): Promise<void> {
117
+ const state = createConnection({ id: opts.connId, connectedSince: opts.idleSince });
118
+ state.registration = 'registered';
119
+ state.lastSeen = opts.idleSince;
120
+ if (opts.nick !== undefined) {
121
+ state.nick = opts.nick as Nick;
122
+ state.user = opts.nick;
123
+ await fx!.client.send(
124
+ new PutCommand({
125
+ TableName: fx!.tables.Nicks,
126
+ Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
127
+ }),
128
+ );
129
+ }
130
+ for (const chan of opts.channels ?? []) {
131
+ state.joinedChannels.add(chan);
132
+ await fx!.client.send(
133
+ new PutCommand({
134
+ TableName: fx!.tables.ChannelMembers,
135
+ Item: marshalChannelMember(chan.toLowerCase(), {
136
+ conn: opts.connId,
137
+ nick: (opts.nick ?? 'x') as Nick,
138
+ op: false,
139
+ voice: false,
140
+ }),
141
+ }),
142
+ );
143
+ }
144
+ await fx!.client.send(
145
+ new PutCommand({
146
+ TableName: fx!.tables.Connections,
147
+ Item: marshalConnection(state, opts.idleSince),
148
+ }),
149
+ );
150
+ }
151
+
152
+ async function connectionExists(connId: string): Promise<boolean> {
153
+ const res = await fx!.client.send(
154
+ new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
155
+ );
156
+ return res.Item !== undefined;
157
+ }
158
+
159
+ async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
160
+ const { QueryCommand } = await import('@aws-sdk/lib-dynamodb');
161
+ const res = await fx!.client.send(
162
+ new QueryCommand({
163
+ TableName: fx!.tables.ChannelMembers,
164
+ KeyConditionExpression: 'channelName = :k',
165
+ ExpressionAttributeValues: { ':k': channelKey },
166
+ }),
167
+ );
168
+ return (res.Items ?? []) as { connectionId: string }[];
169
+ }
170
+
171
+ async function nickExists(nick: string): Promise<boolean> {
172
+ const res = await fx!.client.send(
173
+ new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
174
+ );
175
+ return res.Item !== undefined;
176
+ }
177
+
178
+ describe.skipIf(!available)('idle / PING checker', () => {
179
+ beforeEach(async () => {
180
+ fx = await makeFixture();
181
+ });
182
+ afterEach(async () => {
183
+ if (fx) await fx.cleanup();
184
+ fx = null;
185
+ });
186
+
187
+ // -------------------------------------------------------------------------
188
+ // Acceptance criteria
189
+ // -------------------------------------------------------------------------
190
+
191
+ it('sends a PING to an idle connection past the PING interval', async () => {
192
+ const pingInterval = 60_000;
193
+ const now = 100_000;
194
+ await seedConnection({ connId: 'idle', idleSince: 0 }); // idleFor = 100s > 60s
195
+
196
+ const result: PingCheckResult = await handlePingCheck(
197
+ params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
198
+ );
199
+
200
+ expect(result.scanned).toBe(1);
201
+ expect(result.pinged).toBe(1);
202
+ expect(result.disconnected).toBe(0);
203
+ expect(fx!.mgmt.calls.size).toBe(1);
204
+ expect(fx!.mgmt.calls.has('idle')).toBe(true);
205
+ // PING line format: `PING :<token>` (token is opaque, server-chosen).
206
+ const line = fx!.mgmt.calls.get('idle')!.join('\r\n');
207
+ expect(line).toMatch(/^PING :.+\r?\n?$/);
208
+ });
209
+
210
+ it('disconnects a connection that has not answered PING within the PONG window', async () => {
211
+ const pingInterval = 60_000;
212
+ const pongTimeout = 60_000;
213
+ // Idle for 2× interval → no PONG within window → disconnect.
214
+ const now = (pingInterval + pongTimeout) * 2;
215
+ await seedConnection({ connId: 'ghost', idleSince: 0 });
216
+
217
+ const result = await handlePingCheck(
218
+ params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
219
+ );
220
+
221
+ expect(result.disconnected).toBe(1);
222
+ expect(await connectionExists('ghost')).toBe(false);
223
+ // No PING is sent in the disconnect window — past it.
224
+ expect(fx!.mgmt.calls.size).toBe(0);
225
+ });
226
+
227
+ // -------------------------------------------------------------------------
228
+ // Sibling cases (driven by the TDD outline + AC text)
229
+ // -------------------------------------------------------------------------
230
+
231
+ it('leaves an active connection alone (idleFor < pingInterval)', async () => {
232
+ const now = 1_000;
233
+ await seedConnection({ connId: 'live', idleSince: now }); // idleFor = 0
234
+
235
+ const result = await handlePingCheck(
236
+ params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
237
+ );
238
+
239
+ expect(result.scanned).toBe(1);
240
+ expect(result.pinged).toBe(0);
241
+ expect(result.disconnected).toBe(0);
242
+ expect(fx!.mgmt.calls.size).toBe(0);
243
+ expect(await connectionExists('live')).toBe(true);
244
+ });
245
+
246
+ it('disconnect path also removes memberships and releases the nick', async () => {
247
+ const now = 1_000_000;
248
+ await seedConnection({
249
+ connId: 'stale',
250
+ idleSince: 0,
251
+ nick: 'alice',
252
+ channels: ['#foo' as ChanName, '#bar' as ChanName],
253
+ });
254
+
255
+ await handlePingCheck(params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }));
256
+
257
+ expect(await connectionExists('stale')).toBe(false);
258
+ expect(await nickExists('alice')).toBe(false);
259
+ expect(await membersOf('#foo')).toHaveLength(0);
260
+ expect(await membersOf('#bar')).toHaveLength(0);
261
+ });
262
+
263
+ it('treats a PONG-refreshed connection as active (idleSince recent)', async () => {
264
+ // PONG came back recently via $default → idleSince updated → not idle.
265
+ const pingInterval = 60_000;
266
+ const now = 1_000_000;
267
+ await seedConnection({ connId: 'healthy', idleSince: now - 5_000 }); // idleFor = 5s
268
+
269
+ const result = await handlePingCheck(
270
+ params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
271
+ );
272
+
273
+ expect(result.pinged).toBe(0);
274
+ expect(result.disconnected).toBe(0);
275
+ expect(await connectionExists('healthy')).toBe(true);
276
+ });
277
+
278
+ it('disconnects exactly at the idle threshold (idleFor == pingInterval + pongTimeout)', async () => {
279
+ const pingInterval = 60_000;
280
+ const pongTimeout = 60_000;
281
+ const threshold = pingInterval + pongTimeout;
282
+ await seedConnection({ connId: 'edge', idleSince: 0 });
283
+ // now - idleSince == threshold → disconnect.
284
+ const result = await handlePingCheck(
285
+ params({ now: threshold, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
286
+ );
287
+ expect(result.disconnected).toBe(1);
288
+ expect(await connectionExists('edge')).toBe(false);
289
+ });
290
+
291
+ it('pings but does not disconnect just below the disconnect threshold', async () => {
292
+ const pingInterval = 60_000;
293
+ const pongTimeout = 60_000;
294
+ const threshold = pingInterval + pongTimeout;
295
+ await seedConnection({ connId: 'waiting', idleSince: 0 });
296
+ const result = await handlePingCheck(
297
+ params({ now: threshold - 1, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
298
+ );
299
+ expect(result.pinged).toBe(1);
300
+ expect(result.disconnected).toBe(0);
301
+ expect(await connectionExists('waiting')).toBe(true);
302
+ });
303
+
304
+ it('does not ping just below the PING threshold', async () => {
305
+ const pingInterval = 60_000;
306
+ await seedConnection({ connId: 'fresh', idleSince: 0 });
307
+ const result = await handlePingCheck(
308
+ params({ now: pingInterval - 1, pingIntervalMs: pingInterval, pongTimeoutMs: 60_000 }),
309
+ );
310
+ expect(result.pinged).toBe(0);
311
+ expect(result.disconnected).toBe(0);
312
+ });
313
+
314
+ it('handles a mixed table: one PING, one disconnect, one active', async () => {
315
+ const pingInterval = 60_000;
316
+ const pongTimeout = 60_000;
317
+ const now = (pingInterval + pongTimeout) * 2;
318
+ await seedConnection({ connId: 'live', idleSince: now });
319
+ await seedConnection({ connId: 'pingMe', idleSince: now - pingInterval }); // idleFor = 60s
320
+ await seedConnection({ connId: 'reap', idleSince: 0 }); // idleFor = 240s
321
+
322
+ const result = await handlePingCheck(
323
+ params({ now, pingIntervalMs: pingInterval, pongTimeoutMs: pongTimeout }),
324
+ );
325
+
326
+ expect(result.scanned).toBe(3);
327
+ expect(result.pinged).toBe(1);
328
+ expect(result.disconnected).toBe(1);
329
+ expect(await connectionExists('live')).toBe(true);
330
+ expect(await connectionExists('pingMe')).toBe(true);
331
+ expect(await connectionExists('reap')).toBe(false);
332
+ expect([...fx!.mgmt.calls.keys()]).toEqual(['pingMe']);
333
+ });
334
+
335
+ it('uses Date.now() when `now` is omitted (real-clock default path)', async () => {
336
+ // Seed a connection idle in the distant past; whatever Date.now() is,
337
+ // it's idle past the disconnect threshold.
338
+ await seedConnection({ connId: 'ancient', idleSince: 0 });
339
+ const result = await handlePingCheck(params({ pingIntervalMs: 1_000, pongTimeoutMs: 1_000 }));
340
+ expect(result.disconnected).toBeGreaterThanOrEqual(1);
341
+ });
342
+
343
+ it('applies the documented default thresholds when none are passed', async () => {
344
+ // Sanity: defaults are positive and PONG timeout > 0.
345
+ expect(DEFAULT_PING_INTERVAL_MS).toBeGreaterThan(0);
346
+ expect(DEFAULT_PONG_TIMEOUT_MS).toBeGreaterThan(0);
347
+ // Seed a connection idle for 10× the default disconnect threshold → reaped.
348
+ const big = DEFAULT_PING_INTERVAL_MS + DEFAULT_PONG_TIMEOUT_MS + 1;
349
+ await seedConnection({ connId: 'gone', idleSince: -big });
350
+ const result = await handlePingCheck(params({ now: 0 }));
351
+ expect(result.disconnected).toBe(1);
352
+ });
353
+
354
+ it('is idempotent — running twice pings once then sweeps 0 (connection already removed)', async () => {
355
+ const now = 1_000_000;
356
+ await seedConnection({ connId: 'stale', idleSince: 0 });
357
+ const first = await handlePingCheck(
358
+ params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
359
+ );
360
+ const second = await handlePingCheck(
361
+ params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
362
+ );
363
+ expect(first.disconnected).toBe(1);
364
+ expect(second.scanned).toBe(0);
365
+ expect(second.pinged).toBe(0);
366
+ expect(second.disconnected).toBe(0);
367
+ });
368
+
369
+ it('paginates: scans across multiple Scan pages', async () => {
370
+ await seedConnection({ connId: 'p1', idleSince: 0 });
371
+ await seedConnection({ connId: 'p2', idleSince: 0 });
372
+ const result = await handlePingCheck(
373
+ params({ now: 1_000_000, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
374
+ );
375
+ expect(result.scanned).toBe(2);
376
+ expect(result.disconnected).toBe(2);
377
+ });
378
+
379
+ it('skips a row whose idleSince is not a number (defensive)', async () => {
380
+ await fx!.client.send(
381
+ new PutCommand({
382
+ TableName: fx!.tables.Connections,
383
+ Item: {
384
+ connectionId: 'corrupt',
385
+ registration: 'registered',
386
+ capNegotiating: false,
387
+ caps: [],
388
+ userModes: {},
389
+ lastSeen: 0,
390
+ connectedSince: 0,
391
+ idleSince: 'not-a-number',
392
+ version: 1,
393
+ },
394
+ }),
395
+ );
396
+ const result = await handlePingCheck(
397
+ params({ now: 1_000_000, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
398
+ );
399
+ expect(result.scanned).toBe(1);
400
+ expect(result.pinged).toBe(0);
401
+ expect(result.disconnected).toBe(0);
402
+ expect(await connectionExists('corrupt')).toBe(true);
403
+ });
404
+
405
+ it('catches GoneException on PING send and cleans up the connection', async () => {
406
+ // The socket is gone but the row is still there. handlePingCheck's
407
+ // send path should catch GoneException and reap the row, returning
408
+ // the connection as disconnected.
409
+ const now = 100_000;
410
+ await seedConnection({ connId: 'halfopen', idleSince: 0 }); // idleFor = 100s
411
+ fx!.mgmt.failNextWithGone('halfopen');
412
+
413
+ const result = await handlePingCheck(
414
+ params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 }),
415
+ );
416
+
417
+ expect(result.pinged).toBe(1); // PING attempt counted even though send threw
418
+ expect(await connectionExists('halfopen')).toBe(false);
419
+ });
420
+
421
+ it('is safe to run when managementApi is null (PING path is a silent no-op)', async () => {
422
+ const now = 100_000;
423
+ await seedConnection({ connId: 'idle', idleSince: 0 });
424
+ const result = await handlePingCheck(
425
+ params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000, managementApi: null }),
426
+ );
427
+ expect(result.pinged).toBe(0); // no backend → no PING attempt counted
428
+ expect(await connectionExists('idle')).toBe(true);
429
+ });
430
+
431
+ it('rethrows non-Gone errors from the PING send (no silent swallow)', async () => {
432
+ const now = 100_000;
433
+ await seedConnection({ connId: 'idle', idleSince: 0 });
434
+ fx!.mgmt.throwNext('idle', new Error('InternalServerError: boom'));
435
+
436
+ await expect(
437
+ handlePingCheck(params({ now, pingIntervalMs: 60_000, pongTimeoutMs: 60_000 })),
438
+ ).rejects.toThrow(/boom/);
439
+ // The row is still there — we did not run cleanupConnection.
440
+ expect(await connectionExists('idle')).toBe(true);
441
+ });
442
+ });
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Scan-response edge cases (no DynamoDB Local — fake client)
446
+ // ---------------------------------------------------------------------------
447
+
448
+ /**
449
+ * Scripted page for the fake Scan client (mirrors `sweeper.test.ts`).
450
+ */
451
+ interface ScriptedPage {
452
+ items?: unknown[];
453
+ lastKey?: unknown;
454
+ }
455
+
456
+ /**
457
+ * Fake document client: returns scripted `ScanCommand` pages and empty
458
+ * success for every other command. Lets the checker's pagination and
459
+ * defensive branches run without real DynamoDB.
460
+ */
461
+ class FakeScanDynamo {
462
+ public sent: unknown[] = [];
463
+ constructor(private readonly pages: ScriptedPage[]) {}
464
+ async send(cmd: unknown): Promise<unknown> {
465
+ this.sent.push(cmd);
466
+ if (cmd instanceof ScanCommand) {
467
+ const page = this.pages.shift() ?? {};
468
+ const resp: Record<string, unknown> = {};
469
+ if (page.items !== undefined) resp.Items = page.items;
470
+ if (page.lastKey !== undefined) resp.LastEvaluatedKey = page.lastKey;
471
+ return resp;
472
+ }
473
+ return {};
474
+ }
475
+ }
476
+
477
+ function fakeParams(client: FakeScanDynamo): PingCheckParams {
478
+ return {
479
+ dynamo: client as unknown as DynamoDBDocumentClient,
480
+ tables: {
481
+ Connections: 'C',
482
+ ChannelMeta: 'CM',
483
+ ChannelMembers: 'CMem',
484
+ Nicks: 'N',
485
+ Accounts: 'A',
486
+ },
487
+ managementApi: null,
488
+ now: 100_000,
489
+ pingIntervalMs: 60_000,
490
+ pongTimeoutMs: 60_000,
491
+ };
492
+ }
493
+
494
+ describe('idle / PING checker — scan edge cases', () => {
495
+ it('paginates across multiple Scan pages (LastEvaluatedKey carried between calls)', async () => {
496
+ const client = new FakeScanDynamo([
497
+ { items: [{ connectionId: 'a', idleSince: 0 }], lastKey: { connectionId: 'a' } },
498
+ { items: [{ connectionId: 'b', idleSince: 0 }], lastKey: { connectionId: 'b' } },
499
+ { items: [{ connectionId: 'c', idleSince: 0 }] },
500
+ ]);
501
+ const result = await handlePingCheck(fakeParams(client));
502
+ expect(result.scanned).toBe(3);
503
+ // managementApi is null → pinged stays 0 even though idleFor > pingInterval.
504
+ expect(result.pinged).toBe(0);
505
+ });
506
+
507
+ it('treats a missing Items field as an empty page (defensive)', async () => {
508
+ const client = new FakeScanDynamo([{}]);
509
+ const result = await handlePingCheck(fakeParams(client));
510
+ expect(result.scanned).toBe(0);
511
+ expect(result.pinged).toBe(0);
512
+ expect(result.disconnected).toBe(0);
513
+ });
514
+
515
+ it('skips a row whose connectionId is not a string (defensive)', async () => {
516
+ // Real DynamoDB rejects non-string PKs at write time, so this only
517
+ // surfaces in synthetic scans. The fake client lets us inject the
518
+ // malformed row directly.
519
+ const client = new FakeScanDynamo([{ items: [{ connectionId: 12345, idleSince: 0 }] }]);
520
+ const result = await handlePingCheck(fakeParams(client));
521
+ expect(result.scanned).toBe(1);
522
+ expect(result.pinged).toBe(0);
523
+ expect(result.disconnected).toBe(0);
524
+ });
525
+ });
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Test double for ApiGatewayManagementApi.postToConnection
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Records every `postToConnection` call, grouped by ConnectionId.
533
+ * Optionally throws a GoneException-shaped error on the next call for
534
+ * a registered connection id, so the PING path's gone-cleanup branch
535
+ * is exercisable without real APIGW.
536
+ */
537
+ class RecordingPostToConnection {
538
+ readonly calls = new Map<string, string[]>();
539
+ private readonly goneNext = new Set<string>();
540
+ private readonly throwNextErr = new Map<string, Error>();
541
+ register(connId: string): void {
542
+ this.calls.set(connId, []);
543
+ }
544
+ failNextWithGone(connId: string): void {
545
+ this.goneNext.add(connId);
546
+ }
547
+ /** Next call for `connId` throws `err` verbatim (used to exercise the non-Gone rethrow). */
548
+ throwNext(connId: string, err: Error): void {
549
+ this.throwNextErr.set(connId, err);
550
+ }
551
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
552
+ const throwErr = this.throwNextErr.get(input.ConnectionId);
553
+ if (throwErr !== undefined) {
554
+ this.throwNextErr.delete(input.ConnectionId);
555
+ throw throwErr;
556
+ }
557
+ if (this.goneNext.has(input.ConnectionId)) {
558
+ this.goneNext.delete(input.ConnectionId);
559
+ const err = new Error('Gone');
560
+ (err as { name: string }).name = 'GoneException';
561
+ throw err;
562
+ }
563
+ const list = this.calls.get(input.ConnectionId) ?? [];
564
+ list.push(input.Data);
565
+ this.calls.set(input.ConnectionId, list);
566
+ return {};
567
+ }
568
+ }
569
+
570
+ // Keep the import live for type usage in `params`.
571
+ export type { DynamoDBDocumentClient };