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,446 @@
1
+ /**
2
+ * Membership atomicity for JOIN / PART / KICK (the DynamoDB
3
+ * `TransactWriteItems` path).
4
+ *
5
+ * Each membership mutation must span BOTH tables in a single transaction:
6
+ * - `ChannelMembers` (the authoritative roster), AND
7
+ * - `Connections.joinedChannels` (the per-connection joined set used
8
+ * by `$disconnect` cleanup).
9
+ *
10
+ * Without the transaction, a concurrent JOIN of two different channels
11
+ * by the same connection leaves `joinedChannels` missing one of them
12
+ * (the second `persistConnectionState` overwrites the first). The
13
+ * stress test below proves the transactions serialise correctly.
14
+ *
15
+ * Gated on `process.env.DDB_AVAILABLE` — needs real DynamoDB Local
16
+ * (TransactWriteItems semantics aren't faithfully mockable).
17
+ */
18
+
19
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
20
+ import { GetCommand, PutCommand, QueryCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
21
+ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc-core';
22
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+ import { AwsRuntime, type AwsRuntimeHandlers } from '../src/aws-runtime.js';
24
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
25
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
26
+ import { marshalChannelMember, marshalConnection } from '../src/serialize.js';
27
+ import type { TablesConfig } from '../src/tables.js';
28
+
29
+ const available = process.env.DDB_AVAILABLE === '1';
30
+ const endpoint = process.env.DYNAMO_ENDPOINT;
31
+
32
+ interface Fixture {
33
+ tables: TablesConfig;
34
+ client: ReturnType<typeof createDynamoDocumentClient>;
35
+ cleanup: () => Promise<void>;
36
+ }
37
+
38
+ let fx: Fixture | null;
39
+
40
+ async function makeFixture(): Promise<Fixture> {
41
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
42
+ const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
43
+ const tables = Object.fromEntries(
44
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
45
+ ) as TablesConfig;
46
+ const client = createDynamoDocumentClient({ endpoint });
47
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
48
+ const pkName = props.partitionKey?.name;
49
+ const skName = props.sortKey?.name;
50
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
51
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
52
+ { AttributeName: pkName, AttributeType: 'S' },
53
+ ];
54
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
55
+ { AttributeName: pkName, KeyType: 'HASH' },
56
+ ];
57
+ if (skName !== undefined) {
58
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
59
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
60
+ }
61
+ await client.send(
62
+ new CreateTableCommand({
63
+ TableName: `${prefix}${logical}`,
64
+ AttributeDefinitions: attributeDefinitions,
65
+ KeySchema: keySchema,
66
+ BillingMode: 'PAY_PER_REQUEST',
67
+ }),
68
+ );
69
+ }
70
+ return {
71
+ tables,
72
+ client,
73
+ cleanup: async () => {
74
+ for (const logical of Object.keys(TABLE_DEFS)) {
75
+ try {
76
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
77
+ } catch {
78
+ // best-effort
79
+ }
80
+ }
81
+ },
82
+ };
83
+ }
84
+
85
+ function makeRuntime(connId: string): AwsRuntime {
86
+ const handlers: AwsRuntimeHandlers = {
87
+ send: () => {},
88
+ disconnect: () => {},
89
+ snapshot: () => undefined,
90
+ };
91
+ return new AwsRuntime({
92
+ dynamo: fx!.client,
93
+ tables: fx!.tables,
94
+ connId,
95
+ handlers,
96
+ managementApi: null,
97
+ });
98
+ }
99
+
100
+ /** Persists a fresh Connections row with no joined channels. */
101
+ async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
102
+ const state = createConnection({ id: connId, connectedSince: idleSince });
103
+ await fx!.client.send(
104
+ new PutCommand({
105
+ TableName: fx!.tables.Connections,
106
+ Item: marshalConnection(state, idleSince),
107
+ }),
108
+ );
109
+ }
110
+
111
+ /** Reads the raw Connections row (so we can inspect the stored shape). */
112
+ async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
113
+ const res = await fx!.client.send(
114
+ new GetCommand({
115
+ TableName: fx!.tables.Connections,
116
+ Key: { connectionId: connId },
117
+ }),
118
+ );
119
+ return res.Item as { joinedChannels?: unknown } | undefined;
120
+ }
121
+
122
+ /** Reads every ChannelMembers row for a channel. */
123
+ async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
124
+ const res = await fx!.client.send(
125
+ new QueryCommand({
126
+ TableName: fx!.tables.ChannelMembers,
127
+ KeyConditionExpression: 'channelName = :k',
128
+ ExpressionAttributeValues: { ':k': channelKey },
129
+ }),
130
+ );
131
+ return (res.Items ?? []) as { connectionId: string }[];
132
+ }
133
+
134
+ /** Seeds a ChannelMembers row + reflects it on the connection's joined set. */
135
+ async function seedMembership(connId: string, nick: string, channel: ChanName): Promise<void> {
136
+ const state = createConnection({ id: connId, connectedSince: 1_000 });
137
+ state.registration = 'registered';
138
+ state.nick = nick as Nick;
139
+ state.joinedChannels.add(channel);
140
+ await fx!.client.send(
141
+ new PutCommand({
142
+ TableName: fx!.tables.Connections,
143
+ Item: marshalConnection(state, 1_000),
144
+ }),
145
+ );
146
+ await fx!.client.send(
147
+ new PutCommand({
148
+ TableName: fx!.tables.ChannelMembers,
149
+ Item: marshalChannelMember(channel.toLowerCase(), {
150
+ conn: connId,
151
+ nick: nick as Nick,
152
+ op: false,
153
+ voice: false,
154
+ }),
155
+ }),
156
+ );
157
+ }
158
+
159
+ describe.skipIf(!available)('membership transactions', () => {
160
+ beforeEach(async () => {
161
+ fx = await makeFixture();
162
+ });
163
+ afterEach(async () => {
164
+ if (fx) await fx.cleanup();
165
+ fx = null;
166
+ });
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Atomicity: ChannelMembers ↔ Connections.joinedChannels
170
+ // -------------------------------------------------------------------------
171
+
172
+ it('JOIN writes both the ChannelMembers row and the channel to Connections.joinedChannels', async () => {
173
+ await seedConnection('c1');
174
+ const rt = makeRuntime('caller');
175
+
176
+ await rt.applyChannelDelta('#foo' as ChanName, {
177
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
178
+ });
179
+
180
+ const members = await readMembers('#foo');
181
+ expect(members.map((m) => m.connectionId)).toEqual(['c1']);
182
+
183
+ const row = await readConnection('c1');
184
+ expect(row).toBeDefined();
185
+ expect(row!.joinedChannels).toBeInstanceOf(Set);
186
+ expect((row!.joinedChannels as Set<string>).has('#foo')).toBe(true);
187
+ });
188
+
189
+ it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
190
+ await seedMembership('c1', 'Alice', '#foo' as ChanName);
191
+ const rt = makeRuntime('caller');
192
+
193
+ await rt.applyChannelDelta('#foo' as ChanName, {
194
+ memberships: [{ type: 'remove', conn: 'c1' }],
195
+ });
196
+
197
+ const members = await readMembers('#foo');
198
+ expect(members).toHaveLength(0);
199
+
200
+ const row = await readConnection('c1');
201
+ expect(row).toBeDefined();
202
+ expect((row!.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
203
+ });
204
+
205
+ it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
206
+ await seedMembership('target', 'Bob', '#room' as ChanName);
207
+ const rt = makeRuntime('op');
208
+
209
+ await rt.applyChannelDelta('#room' as ChanName, {
210
+ memberships: [{ type: 'remove', conn: 'target' }],
211
+ });
212
+
213
+ const row = await readConnection('target');
214
+ expect((row!.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
215
+ expect(await readMembers('#room')).toHaveLength(0);
216
+ });
217
+
218
+ // -------------------------------------------------------------------------
219
+ // Stress: concurrent mutations by the same connection
220
+ // -------------------------------------------------------------------------
221
+
222
+ it('concurrent JOINs of different channels by the same connection leave both channels in joinedChannels', async () => {
223
+ await seedConnection('c1');
224
+ const rtA = makeRuntime('probe-a');
225
+ const rtB = makeRuntime('probe-b');
226
+
227
+ await Promise.all([
228
+ rtA.applyChannelDelta('#foo' as ChanName, {
229
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
230
+ }),
231
+ rtB.applyChannelDelta('#bar' as ChanName, {
232
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
233
+ }),
234
+ ]);
235
+
236
+ const row = await readConnection('c1');
237
+ const joined = row!.joinedChannels as Set<string>;
238
+ expect(joined.has('#foo')).toBe(true);
239
+ expect(joined.has('#bar')).toBe(true);
240
+
241
+ expect((await readMembers('#foo')).map((m) => m.connectionId)).toEqual(['c1']);
242
+ expect((await readMembers('#bar')).map((m) => m.connectionId)).toEqual(['c1']);
243
+ });
244
+
245
+ it('concurrent JOINs of the same channel by the same connection are idempotent (one roster row, channel listed once)', async () => {
246
+ await seedConnection('c1');
247
+ const rtA = makeRuntime('probe-a');
248
+ const rtB = makeRuntime('probe-b');
249
+
250
+ await expect(
251
+ Promise.all([
252
+ rtA.applyChannelDelta('#foo' as ChanName, {
253
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
254
+ }),
255
+ rtB.applyChannelDelta('#foo' as ChanName, {
256
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
257
+ }),
258
+ ]),
259
+ ).resolves.toBeDefined();
260
+
261
+ expect(await readMembers('#foo')).toHaveLength(1);
262
+ const row = await readConnection('c1');
263
+ const joined = row!.joinedChannels as Set<string>;
264
+ expect(joined.has('#foo')).toBe(true);
265
+ expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
266
+ });
267
+
268
+ it('concurrent PARTs of the same channel are idempotent (no rows left, joinedChannels empty)', async () => {
269
+ await seedMembership('c1', 'Alice', '#foo' as ChanName);
270
+ const rtA = makeRuntime('probe-a');
271
+ const rtB = makeRuntime('probe-b');
272
+
273
+ await expect(
274
+ Promise.all([
275
+ rtA.applyChannelDelta('#foo' as ChanName, {
276
+ memberships: [{ type: 'remove', conn: 'c1' }],
277
+ }),
278
+ rtB.applyChannelDelta('#foo' as ChanName, {
279
+ memberships: [{ type: 'remove', conn: 'c1' }],
280
+ }),
281
+ ]),
282
+ ).resolves.toBeDefined();
283
+
284
+ expect(await readMembers('#foo')).toHaveLength(0);
285
+ const row = await readConnection('c1');
286
+ expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
287
+ });
288
+
289
+ // -------------------------------------------------------------------------
290
+ // Sequence: JOIN then PART leaves no trace
291
+ // -------------------------------------------------------------------------
292
+
293
+ it('JOIN then PART on the same connection leaves joinedChannels empty and no roster row', async () => {
294
+ await seedConnection('c1');
295
+ const rt = makeRuntime('caller');
296
+
297
+ await rt.applyChannelDelta('#foo' as ChanName, {
298
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
299
+ });
300
+ await rt.applyChannelDelta('#foo' as ChanName, {
301
+ memberships: [{ type: 'remove', conn: 'c1' }],
302
+ });
303
+
304
+ expect(await readMembers('#foo')).toHaveLength(0);
305
+ const row = await readConnection('c1');
306
+ expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Retry / error-classification (no DynamoDB Local required — fake client)
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Minimal fake document client. Counts `TransactWriteCommand` invocations
316
+ * and throws a configurable error for the first N transact attempts so
317
+ * the retry loop in `AwsRuntime.sendTransactWithRetry` is exercised
318
+ * without needing real DynamoDB concurrency.
319
+ */
320
+ class FakeDynamo {
321
+ transactCalls = 0;
322
+ otherCalls = 0;
323
+ constructor(
324
+ private readonly opts: {
325
+ failTransactTimes?: number;
326
+ error?: { name: string; CancellationReasons?: Array<{ Code?: string }> };
327
+ },
328
+ ) {}
329
+ async send(cmd: unknown): Promise<unknown> {
330
+ if (cmd instanceof TransactWriteCommand) {
331
+ this.transactCalls++;
332
+ const fail = this.opts.failTransactTimes ?? 0;
333
+ if (this.transactCalls <= fail) {
334
+ throw (
335
+ this.opts.error ?? {
336
+ name: 'TransactionCanceledException',
337
+ CancellationReasons: [{ Code: 'TransactionConflict' }],
338
+ }
339
+ );
340
+ }
341
+ return {};
342
+ }
343
+ this.otherCalls++;
344
+ // ensureMetaRow does a PutCommand (ChannelMeta); return success.
345
+ return {};
346
+ }
347
+ }
348
+
349
+ function fakeRuntime(client: FakeDynamo): AwsRuntime {
350
+ const handlers: AwsRuntimeHandlers = {
351
+ send: () => {},
352
+ disconnect: () => {},
353
+ snapshot: () => undefined,
354
+ };
355
+ return new AwsRuntime({
356
+ dynamo: client as unknown as ReturnType<typeof createDynamoDocumentClient>,
357
+ tables: {
358
+ Connections: 'C',
359
+ ChannelMeta: 'CM',
360
+ ChannelMembers: 'CMem',
361
+ Nicks: 'N',
362
+ Accounts: 'A',
363
+ },
364
+ connId: 'probe',
365
+ handlers,
366
+ managementApi: null,
367
+ });
368
+ }
369
+
370
+ describe('membership transaction retry / error classification', () => {
371
+ it('retries on TransactionConflict and succeeds once the conflict clears', async () => {
372
+ const fake = new FakeDynamo({ failTransactTimes: 2 });
373
+ const rt = fakeRuntime(fake);
374
+
375
+ await rt.applyChannelDelta('#foo' as ChanName, {
376
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
377
+ });
378
+
379
+ // 2 failed attempts + 1 successful = 3 transact calls.
380
+ expect(fake.transactCalls).toBe(3);
381
+ });
382
+
383
+ it('rethrows a non-conflict TransactionCanceledException immediately (no retry)', async () => {
384
+ const fake = new FakeDynamo({
385
+ failTransactTimes: 1,
386
+ error: {
387
+ name: 'TransactionCanceledException',
388
+ CancellationReasons: [{ Code: 'ConditionalCheckFailed' }],
389
+ },
390
+ });
391
+ const rt = fakeRuntime(fake);
392
+
393
+ await expect(
394
+ rt.applyChannelDelta('#foo' as ChanName, {
395
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
396
+ }),
397
+ ).rejects.toThrow();
398
+ expect(fake.transactCalls).toBe(1);
399
+ });
400
+
401
+ it('rethrows after exhausting the retry budget on persistent conflicts', async () => {
402
+ const fake = new FakeDynamo({ failTransactTimes: 999 });
403
+ const rt = fakeRuntime(fake);
404
+
405
+ await expect(
406
+ rt.applyChannelDelta('#foo' as ChanName, {
407
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
408
+ }),
409
+ ).rejects.toThrow();
410
+ // MAX_ATTEMPTS = 6.
411
+ expect(fake.transactCalls).toBe(6);
412
+ });
413
+
414
+ it('rethrows a non-transact error (e.g. ProvisionedThroughputExceeded) without retry', async () => {
415
+ const fake = new FakeDynamo({
416
+ failTransactTimes: 1,
417
+ error: { name: 'ProvisionedThroughputExceededException' },
418
+ });
419
+ const rt = fakeRuntime(fake);
420
+
421
+ await expect(
422
+ rt.applyChannelDelta('#foo' as ChanName, {
423
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
424
+ }),
425
+ ).rejects.toThrow();
426
+ expect(fake.transactCalls).toBe(1);
427
+ });
428
+
429
+ it('rejects a TransactionConflict classifier on null / non-object errors', async () => {
430
+ // Smoke-test the classifier's defensive branches by replaying the
431
+ // same fake-runtime path with a non-object error. The classifier
432
+ // must treat it as non-retryable and rethrow on the first attempt.
433
+ const fake = new FakeDynamo({
434
+ failTransactTimes: 1,
435
+ error: { name: 'TransactionCanceledException' }, // no CancellationReasons
436
+ });
437
+ const rt = fakeRuntime(fake);
438
+
439
+ await expect(
440
+ rt.applyChannelDelta('#foo' as ChanName, {
441
+ memberships: [{ type: 'add', conn: 'c1', nick: 'Alice' as Nick }],
442
+ }),
443
+ ).rejects.toThrow();
444
+ expect(fake.transactCalls).toBe(1);
445
+ });
446
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../irc-core/tsconfig.build.json" },
14
+ { "path": "../irc-server/tsconfig.build.json" }
15
+ ]
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"]
6
+ },
7
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
8
+ "exclude": ["dist"],
9
+ "references": [
10
+ { "path": "../irc-core/tsconfig.build.json" },
11
+ { "path": "../irc-server/tsconfig.build.json" },
12
+ { "path": "../irc-test-support/tsconfig.build.json" }
13
+ ]
14
+ }
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globalSetup: ['./tests/global-setup.ts'],
6
+ setupFiles: [],
7
+ include: ['tests/**/*.test.ts'],
8
+ exclude: ['dist/**', 'node_modules/**'],
9
+ coverage: {
10
+ provider: 'v8',
11
+ all: false,
12
+ include: ['src/**/*.ts'],
13
+ exclude: ['src/**/index.ts'],
14
+ reporter: ['text', 'html', 'json-summary'],
15
+ thresholds: {
16
+ lines: 90,
17
+ functions: 90,
18
+ branches: 90,
19
+ statements: 90,
20
+ },
21
+ },
22
+ },
23
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -30,6 +30,7 @@
30
30
  "@cloudflare/vitest-pool-workers": "0.18.6",
31
31
  "@cloudflare/workers-types": "^5.20260714.1",
32
32
  "@serverless-ircd/in-memory-runtime": "workspace:*",
33
+ "@serverless-ircd/irc-test-support": "workspace:*",
33
34
  "@vitest/coverage-v8": "^4.1.0",
34
35
  "rimraf": "^6.0.0",
35
36
  "typescript": "^5.6.0",
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Cloudflare Workers config loader.
3
+ *
4
+ * Adapts the platform-native env vars (Workers `vars` in `wrangler.toml`
5
+ * or per-environment secrets) into the shared `ServerConfigSchema` from
6
+ * `irc-core`. Both adapters (CF + AWS) consume the same schema; only the
7
+ * env-shape mapping differs per platform.
8
+ *
9
+ * String env vars are coerced into the schema's expected types here so
10
+ * operators can express every config knob as a wrangler var/secret
11
+ * without per-type plumbing in the worker.
12
+ */
13
+
14
+ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
15
+
16
+ /**
17
+ * The subset of the Workers `Env` that this loader reads. Mirrors the
18
+ * production {@link Env} in `env.ts` plus the optional knobs the schema
19
+ * recognises. Optional members are omitted from the input object when
20
+ * undefined so the schema's defaults apply.
21
+ */
22
+ export interface CfConfigEnv {
23
+ SERVER_NAME?: string;
24
+ NETWORK_NAME?: string;
25
+ MOTD_LINES?: string;
26
+ MAX_CLIENTS?: string;
27
+ CHANNEL_PREFIXES?: string;
28
+ OPER_USER?: string;
29
+ OPER_PASSWORD?: string;
30
+ MAX_CHANNELS_PER_USER?: string;
31
+ MAX_TARGETS_PER_COMMAND?: string;
32
+ NICK_LEN?: string;
33
+ CHANNEL_LEN?: string;
34
+ TOPIC_LEN?: string;
35
+ MAX_LIST_ENTRIES?: string;
36
+ QUIT_MESSAGE?: string;
37
+ }
38
+
39
+ /**
40
+ * Builds the input object the shared schema expects from a Workers env.
41
+ *
42
+ * Helper extracted so the assemble→parse boundary is unit-testable in
43
+ * isolation: the produced input is a plain object that exactly matches
44
+ * `ServerConfigSchema`'s shape (strings converted to numbers / arrays
45
+ * as needed). Missing optional fields are kept out so the schema
46
+ * defaults kick in.
47
+ */
48
+ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
49
+ const input: Record<string, unknown> = {
50
+ serverName: env.SERVER_NAME,
51
+ networkName: env.NETWORK_NAME,
52
+ };
53
+ if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
54
+ input.motdLines = env.MOTD_LINES.split('\n');
55
+ }
56
+ if (env.MAX_CLIENTS !== undefined) {
57
+ input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
58
+ }
59
+ if (env.CHANNEL_PREFIXES !== undefined) {
60
+ input.channelPrefixes = env.CHANNEL_PREFIXES;
61
+ }
62
+ if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
63
+ // Build the cred object from whatever was supplied; the schema
64
+ // surfaces a `user`/`password` error if only one half was provided.
65
+ input.operCreds = [
66
+ {
67
+ user: env.OPER_USER ?? '',
68
+ password: env.OPER_PASSWORD ?? '',
69
+ },
70
+ ];
71
+ }
72
+ if (env.MAX_CHANNELS_PER_USER !== undefined) {
73
+ input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
74
+ }
75
+ if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
76
+ input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
77
+ }
78
+ if (env.NICK_LEN !== undefined) {
79
+ input.nickLen = Number.parseInt(env.NICK_LEN, 10);
80
+ }
81
+ if (env.CHANNEL_LEN !== undefined) {
82
+ input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
83
+ }
84
+ if (env.TOPIC_LEN !== undefined) {
85
+ input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
86
+ }
87
+ if (env.MAX_LIST_ENTRIES !== undefined) {
88
+ input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
89
+ }
90
+ if (env.QUIT_MESSAGE !== undefined) {
91
+ input.quitMessage = env.QUIT_MESSAGE;
92
+ }
93
+ return input;
94
+ }
95
+
96
+ /**
97
+ * Loads and validates the server config from a Cloudflare Workers env.
98
+ *
99
+ * Wraps the shared {@link parseServerConfig} so a single boot-time
100
+ * failure mode covers every adapter: invalid config throws a readable
101
+ * `Error` listing every offending field, surfacing the problem before
102
+ * any connection is accepted.
103
+ */
104
+ export function loadServerConfigFromCfEnv(env: CfConfigEnv): ParsedServerConfig {
105
+ return parseServerConfig(buildConfigInput(env));
106
+ }