serverless-ircd 0.1.0 → 0.2.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 (198) 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/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * AwsHarness — implements the {@link IrcHarnessFactory} port from
3
+ * `@serverless-ircd/irc-test-support` against the DynamoDB-backed
4
+ * {@link AwsRuntime}.
5
+ *
6
+ * Each {@link AwsHarness} instance:
7
+ * 1. Generates a unique table-name prefix (so parallel test runs share
8
+ * one DynamoDB Local instance without colliding).
9
+ * 2. Creates five physical tables against `process.env.DYNAMO_ENDPOINT`.
10
+ * 3. Constructs a single `LocalPostToConnection` test double shared by
11
+ * every spawned runtime so cross-connection sends route in-process.
12
+ * 4. `spawnClient` mints a connection id, persists a Connections row,
13
+ * builds an `AwsRuntime` + `ConnectionActor` + `AwsChannelAccess`,
14
+ * and registers the new client's sink with the shared management
15
+ * double so other connections can postToConnection to it.
16
+ * 5. `close()` deletes the five tables and clears every client.
17
+ *
18
+ * Mirrors `cf-adapter/tests/integration/in-memory-harness.ts` plus the
19
+ * cross-process send plumbing from `cf-adapter/tests/cf-harness.ts`.
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto';
23
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
24
+ import {
25
+ type ChanName,
26
+ type ChannelState,
27
+ type Clock,
28
+ type ConnId,
29
+ type ConnectionState,
30
+ type RawLine,
31
+ SequentialIdFactory,
32
+ StaticMotdProvider,
33
+ SystemClock,
34
+ createConnection,
35
+ } from '@serverless-ircd/irc-core';
36
+ import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
37
+ import type {
38
+ ClientHarness,
39
+ IrcHarness,
40
+ IrcHarnessFactory,
41
+ SpawnClientOptions,
42
+ } from '@serverless-ircd/irc-test-support';
43
+ import { HARNESS_MOTD_LINES, HARNESS_SERVER_CONFIG } from '@serverless-ircd/irc-test-support';
44
+ import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../src/aws-runtime.js';
45
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
46
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
47
+ import type { TablesConfig } from '../src/tables.js';
48
+
49
+ const POLL_MS = 10;
50
+ const DEFAULT_TIMEOUT_MS = 2000;
51
+
52
+ const endpoint = process.env.DYNAMO_ENDPOINT;
53
+
54
+ /**
55
+ * Factory entry — appends to a suite's FACTORIES array to opt the scenarios
56
+ * into the AWS matrix. Returns a no-op factory when DynamoDB Local is
57
+ * unavailable so `describe.skipIf` short-circuits before any test runs.
58
+ */
59
+ export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
60
+ name: 'aws',
61
+ async create() {
62
+ return new AwsHarness();
63
+ },
64
+ });
65
+
66
+ interface TrackedClient {
67
+ client: AwsClientHandle;
68
+ actor: ConnectionActor;
69
+ state: ConnectionState;
70
+ channelAccess: AwsChannelAccess;
71
+ }
72
+
73
+ /** Per-test DynamoDB-backed harness. */
74
+ export class AwsHarness implements IrcHarness {
75
+ private readonly prefix: string;
76
+ private readonly tables: TablesConfig;
77
+ private readonly client: ReturnType<typeof createDynamoDocumentClient>;
78
+ private readonly mgmt = new LocalPostToConnection();
79
+ private readonly clock: Clock = SystemClock;
80
+ private readonly ids = new SequentialIdFactory();
81
+ private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
82
+ private readonly tracked = new Map<ConnId, TrackedClient>();
83
+ private nextId = 0;
84
+ private closed = false;
85
+
86
+ constructor() {
87
+ if (endpoint === undefined) {
88
+ throw new Error('DYNAMO_ENDPOINT missing — global-setup did not run');
89
+ }
90
+ this.prefix = `t${randomUUID()}-`;
91
+ this.tables = {
92
+ Connections: `${this.prefix}Connections`,
93
+ ChannelMeta: `${this.prefix}ChannelMeta`,
94
+ ChannelMembers: `${this.prefix}ChannelMembers`,
95
+ Nicks: `${this.prefix}Nicks`,
96
+ Accounts: `${this.prefix}Accounts`,
97
+ };
98
+ this.client = createDynamoDocumentClient({ endpoint });
99
+ }
100
+
101
+ async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
102
+ if (this.closed) throw new Error('spawnClient called on a closed harness');
103
+ if (this.tracked.size === 0) {
104
+ // First spawn triggers table creation so an unused harness (e.g. one
105
+ // that hit a thrown assertion before spawnClient) doesn't leak tables.
106
+ await this.createTables();
107
+ }
108
+ const id = opts?.id ?? `c${this.nextId++}`;
109
+ if (this.tracked.has(id)) {
110
+ throw new Error(`duplicate client id: ${id}`);
111
+ }
112
+
113
+ const state = createConnection({ id, connectedSince: this.clock.now() });
114
+ const clientHandle = new AwsClientHandle(id);
115
+
116
+ // Wire the management double so other clients can send to this one.
117
+ this.mgmt.register(id, (data) => {
118
+ for (const line of data.split('\r\n')) {
119
+ if (line.length > 0) clientHandle.pushReceived(line);
120
+ }
121
+ });
122
+
123
+ const handlers: AwsRuntimeHandlers = {
124
+ // Self-send: route directly into received (APIGW would echo back to
125
+ // the open socket — the test doubles that with a local sink).
126
+ send: (lines: RawLine[]) => {
127
+ for (const l of lines) clientHandle.pushReceived(l.text);
128
+ },
129
+ disconnect: () => {
130
+ // Lambda cannot close its own WebSocket from inside the handler
131
+ // (the `$disconnect` route fires when APIGW observes the close).
132
+ // For test purposes we mark the client as disconnected.
133
+ clientHandle.markDisconnected();
134
+ },
135
+ snapshot: () => state,
136
+ };
137
+
138
+ const runtime = new AwsRuntime({
139
+ dynamo: this.client,
140
+ tables: this.tables,
141
+ connId: id,
142
+ handlers,
143
+ managementApi: this.mgmt,
144
+ clock: this.clock,
145
+ });
146
+ await runtime.persistConnectionState(state);
147
+
148
+ const channelAccess = new AwsChannelAccess(runtime, this.clock.now());
149
+
150
+ const actor = new ConnectionActor({
151
+ state,
152
+ runtime,
153
+ channels: channelAccess,
154
+ serverConfig: HARNESS_SERVER_CONFIG,
155
+ clock: this.clock,
156
+ ids: this.ids,
157
+ motd: this.motd,
158
+ });
159
+ clientHandle.bindActor(actor);
160
+
161
+ this.tracked.set(id, { client: clientHandle, actor, state, channelAccess });
162
+
163
+ if (opts?.nick !== undefined) {
164
+ await clientHandle.send(`NICK ${opts.nick}`);
165
+ await clientHandle.send(`USER ${opts.nick} 0 * :${opts.nick}`);
166
+ await clientHandle.waitForNumeric('001', DEFAULT_TIMEOUT_MS);
167
+ }
168
+ return clientHandle;
169
+ }
170
+
171
+ async clientByNick(nick: string): Promise<ClientHarness | undefined> {
172
+ // Build a transient runtime just for the lookup. Every runtime reads
173
+ // from the same DynamoDB tables, so a no-op one suffices.
174
+ const probe = new AwsRuntime({
175
+ dynamo: this.client,
176
+ tables: this.tables,
177
+ connId: '__lookup__',
178
+ handlers: {
179
+ send: () => {},
180
+ disconnect: () => {},
181
+ snapshot: () => undefined,
182
+ },
183
+ managementApi: this.mgmt,
184
+ clock: this.clock,
185
+ });
186
+ const found = await probe.lookupNick(nick);
187
+ if (found === null) return undefined;
188
+ return this.tracked.get(found)?.client;
189
+ }
190
+
191
+ async close(): Promise<void> {
192
+ if (this.closed) return;
193
+ this.closed = true;
194
+ // Best-effort QUIT on every client so peers see PART/QUIT broadcasts.
195
+ for (const { client } of this.tracked.values()) {
196
+ try {
197
+ await client.send('QUIT :client closed');
198
+ } catch {
199
+ // Ignore — the actor swallows teardown errors.
200
+ }
201
+ }
202
+ this.tracked.clear();
203
+ await this.deleteTables();
204
+ }
205
+
206
+ private async createTables(): Promise<void> {
207
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
208
+ const pkName = props.partitionKey?.name;
209
+ const skName = props.sortKey?.name;
210
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
211
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
212
+ { AttributeName: pkName, AttributeType: 'S' },
213
+ ];
214
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
215
+ { AttributeName: pkName, KeyType: 'HASH' },
216
+ ];
217
+ if (skName !== undefined) {
218
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
219
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
220
+ }
221
+ await this.client.send(
222
+ new CreateTableCommand({
223
+ TableName: `${this.prefix}${logical}`,
224
+ AttributeDefinitions: attributeDefinitions,
225
+ KeySchema: keySchema,
226
+ BillingMode: 'PAY_PER_REQUEST',
227
+ }),
228
+ );
229
+ }
230
+ }
231
+
232
+ private async deleteTables(): Promise<void> {
233
+ for (const logical of Object.keys(TABLE_DEFS)) {
234
+ try {
235
+ await this.client.send(new DeleteTableCommand({ TableName: `${this.prefix}${logical}` }));
236
+ } catch {
237
+ // Idempotent — table may not exist if createTables threw.
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Per-connection actor channel access.
245
+ *
246
+ * The actor's reducers mutate a local {@link ChannelState} and emit
247
+ * `ApplyChannelDelta` effects that the runtime forwards to DynamoDB.
248
+ * For commands that *read* channel state (NAMES, KICK, PART), the
249
+ * actor calls `refreshChannel(name)` first — we fetch the authoritative
250
+ * snapshot via {@link AwsRuntime.getChannelSnapshot} and rebuild the
251
+ * local cache so the reducer sees the current roster.
252
+ *
253
+ * Mirrors `cf-adapter/src/connection-do.ts`'s `PassthroughChannelAccess`.
254
+ */
255
+ class AwsChannelAccess implements ActorChannelAccess {
256
+ private readonly cache = new Map<string, ChannelState>();
257
+ constructor(
258
+ private readonly runtime: AwsRuntime,
259
+ private readonly now: number,
260
+ ) {}
261
+
262
+ getOrCreateChannel(name: ChanName): ChannelState {
263
+ const key = name.toLowerCase();
264
+ const existing = this.cache.get(key);
265
+ if (existing !== undefined) return existing;
266
+ const created: ChannelState = {
267
+ name,
268
+ nameLower: key,
269
+ modes: {
270
+ inviteOnly: false,
271
+ topicLock: false,
272
+ noExternal: false,
273
+ moderated: false,
274
+ secret: false,
275
+ private: false,
276
+ },
277
+ banMasks: new Set<string>(),
278
+ members: new Map(),
279
+ pendingInvites: new Set<string>(),
280
+ createdAt: this.now,
281
+ };
282
+ this.cache.set(key, created);
283
+ return created;
284
+ }
285
+
286
+ async refreshChannel(name: ChanName): Promise<void> {
287
+ const key = name.toLowerCase();
288
+ const snap = await this.runtime.getChannelSnapshot(name);
289
+ if (snap === null) return;
290
+ // Don't clobber the cache if the snapshot is empty (the channel
291
+ // was never created authoritative); let the reducer lazily seed.
292
+ if (snap.members.size === 0) {
293
+ // Still preserve modes/topic from meta even without members.
294
+ const existing = this.cache.get(key);
295
+ if (existing !== undefined) {
296
+ existing.modes = { ...snap.modes };
297
+ existing.banMasks = new Set(snap.banMasks);
298
+ existing.pendingInvites = new Set(snap.pendingInvites);
299
+ if (snap.topic !== undefined) existing.topic = snap.topic;
300
+ }
301
+ return;
302
+ }
303
+ const rebuilt: ChannelState = {
304
+ name,
305
+ nameLower: key,
306
+ modes: { ...snap.modes },
307
+ banMasks: new Set(snap.banMasks),
308
+ members: new Map(snap.members),
309
+ pendingInvites: new Set(snap.pendingInvites),
310
+ createdAt: snap.createdAt,
311
+ };
312
+ if (snap.topic !== undefined) rebuilt.topic = snap.topic;
313
+ this.cache.set(key, rebuilt);
314
+ }
315
+ }
316
+
317
+ /** Aws-backed {@link ClientHarness}. */
318
+ class AwsClientHandle implements ClientHarness {
319
+ readonly received: string[] = [];
320
+ private actor: ConnectionActor | undefined;
321
+ private closed = false;
322
+ private disconnected = false;
323
+
324
+ constructor(readonly id: ConnId) {}
325
+
326
+ bindActor(actor: ConnectionActor): void {
327
+ this.actor = actor;
328
+ }
329
+
330
+ markDisconnected(): void {
331
+ this.disconnected = true;
332
+ }
333
+
334
+ pushReceived(text: string): void {
335
+ if (this.disconnected) return;
336
+ this.received.push(text);
337
+ }
338
+
339
+ async send(line: string): Promise<void> {
340
+ if (this.actor === undefined) {
341
+ throw new Error(`client ${this.id} has no bound actor`);
342
+ }
343
+ if (this.closed) return;
344
+ await this.actor.receiveTextFrame(`${line}\r\n`);
345
+ }
346
+
347
+ async waitForLine(
348
+ predicate: (line: string) => boolean,
349
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
350
+ ): Promise<string> {
351
+ const deadline = Date.now() + timeoutMs;
352
+ for (const line of this.received) {
353
+ if (predicate(line)) return line;
354
+ }
355
+ while (Date.now() < deadline) {
356
+ await new Promise((r) => setTimeout(r, POLL_MS));
357
+ for (const line of this.received) {
358
+ if (predicate(line)) return line;
359
+ }
360
+ }
361
+ throw new Error(
362
+ `waitForLine timed out after ${timeoutMs}ms; received: ${JSON.stringify(this.received)}`,
363
+ );
364
+ }
365
+
366
+ async waitForNumeric(
367
+ code: number | string,
368
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
369
+ ): Promise<string> {
370
+ const want = typeof code === 'number' ? code.toString().padStart(3, '0') : code;
371
+ return this.waitForLine((l) => numericEquals(l, want), timeoutMs);
372
+ }
373
+
374
+ async close(): Promise<void> {
375
+ if (this.closed) return;
376
+ this.closed = true;
377
+ if (this.actor !== undefined) {
378
+ try {
379
+ await this.actor.receiveTextFrame('QUIT :client closed\r\n');
380
+ } catch {
381
+ // Swallow — teardown errors are non-fatal.
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
389
+ * calls to the registered sink for `ConnectionId`.
390
+ */
391
+ class LocalPostToConnection implements PostToConnection {
392
+ private readonly sinks = new Map<string, (data: string) => void>();
393
+ register(connId: string, sink: (data: string) => void): void {
394
+ this.sinks.set(connId, sink);
395
+ }
396
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
397
+ const sink = this.sinks.get(input.ConnectionId);
398
+ if (sink !== undefined) sink(input.Data);
399
+ return {};
400
+ }
401
+ }
402
+
403
+ function numericEquals(line: string, code: string): boolean {
404
+ const parts = line.split(' ');
405
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
406
+ const candidate = parts[start];
407
+ return candidate === code;
408
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Parametrized IRC scenarios against the AWS runtime.
3
+ *
4
+ * Registers the {@link awsHarnessFactory} against the shared scenario
5
+ * suite from `@serverless-ircd/irc-test-support`. Each scenario builds
6
+ * a fresh {@link AwsHarness} which mints uniquely-prefixed DynamoDB
7
+ * tables against the global-setup's endpoint.
8
+ *
9
+ * Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
10
+ * DynamoDB Local cannot be booted (no Docker, no Java). When available,
11
+ * every scenario MUST pass — that is the 040 acceptance criterion.
12
+ */
13
+
14
+ import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
15
+ import { awsHarnessFactory } from './aws-harness.js';
16
+
17
+ runIrcScenarios([awsHarnessFactory]);