serverless-ircd 0.3.0 → 0.4.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 (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -34,6 +34,11 @@ interface Fixture {
34
34
 
35
35
  let fx: Fixture | null;
36
36
 
37
+ function requireFx(): Fixture {
38
+ if (!fx) throw new Error('test fixture not initialized');
39
+ return fx;
40
+ }
41
+
37
42
  async function makeFixture(): Promise<Fixture> {
38
43
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
39
44
  const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -81,8 +86,8 @@ async function makeFixture(): Promise<Fixture> {
81
86
 
82
87
  function params(overrides: Partial<SweepParams> = {}): SweepParams {
83
88
  return {
84
- dynamo: fx!.client,
85
- tables: fx!.tables,
89
+ dynamo: requireFx().client,
90
+ tables: requireFx().tables,
86
91
  ...overrides,
87
92
  };
88
93
  }
@@ -100,18 +105,18 @@ async function seedConnection(opts: {
100
105
  if (opts.nick !== undefined) {
101
106
  state.nick = opts.nick as Nick;
102
107
  state.user = opts.nick;
103
- await fx!.client.send(
108
+ await requireFx().client.send(
104
109
  new PutCommand({
105
- TableName: fx!.tables.Nicks,
110
+ TableName: requireFx().tables.Nicks,
106
111
  Item: marshalNick(opts.nick as Nick, opts.connId, opts.idleSince),
107
112
  }),
108
113
  );
109
114
  }
110
115
  for (const chan of opts.channels ?? []) {
111
116
  state.joinedChannels.add(chan);
112
- await fx!.client.send(
117
+ await requireFx().client.send(
113
118
  new PutCommand({
114
- TableName: fx!.tables.ChannelMembers,
119
+ TableName: requireFx().tables.ChannelMembers,
115
120
  Item: marshalChannelMember(chan.toLowerCase(), {
116
121
  conn: opts.connId,
117
122
  nick: (opts.nick ?? 'x') as Nick,
@@ -121,9 +126,9 @@ async function seedConnection(opts: {
121
126
  }),
122
127
  );
123
128
  }
124
- await fx!.client.send(
129
+ await requireFx().client.send(
125
130
  new PutCommand({
126
- TableName: fx!.tables.Connections,
131
+ TableName: requireFx().tables.Connections,
127
132
  Item: marshalConnection(state, opts.idleSince),
128
133
  }),
129
134
  );
@@ -131,16 +136,16 @@ async function seedConnection(opts: {
131
136
 
132
137
  async function connectionExists(connId: string): Promise<boolean> {
133
138
  const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
134
- const res = await fx!.client.send(
135
- new GetCommand({ TableName: fx!.tables.Connections, Key: { connectionId: connId } }),
139
+ const res = await requireFx().client.send(
140
+ new GetCommand({ TableName: requireFx().tables.Connections, Key: { connectionId: connId } }),
136
141
  );
137
142
  return res.Item !== undefined;
138
143
  }
139
144
 
140
145
  async function membersOf(channelKey: string): Promise<{ connectionId: string }[]> {
141
- const res = await fx!.client.send(
146
+ const res = await requireFx().client.send(
142
147
  new QueryCommand({
143
- TableName: fx!.tables.ChannelMembers,
148
+ TableName: requireFx().tables.ChannelMembers,
144
149
  KeyConditionExpression: 'channelName = :k',
145
150
  ExpressionAttributeValues: { ':k': channelKey },
146
151
  }),
@@ -150,8 +155,8 @@ async function membersOf(channelKey: string): Promise<{ connectionId: string }[]
150
155
 
151
156
  async function nickExists(nick: string): Promise<boolean> {
152
157
  const { GetCommand } = await import('@aws-sdk/lib-dynamodb');
153
- const res = await fx!.client.send(
154
- new GetCommand({ TableName: fx!.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
158
+ const res = await requireFx().client.send(
159
+ new GetCommand({ TableName: requireFx().tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
155
160
  );
156
161
  return res.Item !== undefined;
157
162
  }
@@ -281,8 +286,10 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
281
286
 
282
287
  it('cleanupConnection is safe to call on a row the sweeper already removed (no throw)', async () => {
283
288
  await seedConnection({ connId: 'stale', idleSince: 0, nick: 'zed' });
284
- await cleanupConnection(fx!.client, fx!.tables, 'stale', null);
285
- await expect(cleanupConnection(fx!.client, fx!.tables, 'stale', null)).resolves.toBeUndefined();
289
+ await cleanupConnection(requireFx().client, requireFx().tables, 'stale', null);
290
+ await expect(
291
+ cleanupConnection(requireFx().client, requireFx().tables, 'stale', null),
292
+ ).resolves.toBeUndefined();
286
293
  });
287
294
 
288
295
  it('uses Date.now() when now is omitted (real-clock default path)', async () => {
@@ -297,9 +304,9 @@ describe.skipIf(!available)('gone-connection sweeper', () => {
297
304
 
298
305
  it('skips a row whose idleSince is not a number (defensive — corrupted row)', async () => {
299
306
  // Write a Connections row directly with a non-numeric idleSince.
300
- await fx!.client.send(
307
+ await requireFx().client.send(
301
308
  new PutCommand({
302
- TableName: fx!.tables.Connections,
309
+ TableName: requireFx().tables.Connections,
303
310
  Item: {
304
311
  connectionId: 'corrupt',
305
312
  registration: 'registered',
@@ -37,6 +37,11 @@ interface Fixture {
37
37
 
38
38
  let fx: Fixture | null;
39
39
 
40
+ function requireFx(): Fixture {
41
+ if (!fx) throw new Error('test fixture not initialized');
42
+ return fx;
43
+ }
44
+
40
45
  async function makeFixture(): Promise<Fixture> {
41
46
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
42
47
  const prefix = `x${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -89,8 +94,8 @@ function makeRuntime(connId: string): AwsRuntime {
89
94
  snapshot: () => undefined,
90
95
  };
91
96
  return new AwsRuntime({
92
- dynamo: fx!.client,
93
- tables: fx!.tables,
97
+ dynamo: requireFx().client,
98
+ tables: requireFx().tables,
94
99
  connId,
95
100
  handlers,
96
101
  managementApi: null,
@@ -100,9 +105,9 @@ function makeRuntime(connId: string): AwsRuntime {
100
105
  /** Persists a fresh Connections row with no joined channels. */
101
106
  async function seedConnection(connId: string, idleSince = 1_000): Promise<void> {
102
107
  const state = createConnection({ id: connId, connectedSince: idleSince });
103
- await fx!.client.send(
108
+ await requireFx().client.send(
104
109
  new PutCommand({
105
- TableName: fx!.tables.Connections,
110
+ TableName: requireFx().tables.Connections,
106
111
  Item: marshalConnection(state, idleSince),
107
112
  }),
108
113
  );
@@ -110,9 +115,9 @@ async function seedConnection(connId: string, idleSince = 1_000): Promise<void>
110
115
 
111
116
  /** Reads the raw Connections row (so we can inspect the stored shape). */
112
117
  async function readConnection(connId: string): Promise<{ joinedChannels?: unknown } | undefined> {
113
- const res = await fx!.client.send(
118
+ const res = await requireFx().client.send(
114
119
  new GetCommand({
115
- TableName: fx!.tables.Connections,
120
+ TableName: requireFx().tables.Connections,
116
121
  Key: { connectionId: connId },
117
122
  }),
118
123
  );
@@ -121,9 +126,9 @@ async function readConnection(connId: string): Promise<{ joinedChannels?: unknow
121
126
 
122
127
  /** Reads every ChannelMembers row for a channel. */
123
128
  async function readMembers(channelKey: string): Promise<{ connectionId: string }[]> {
124
- const res = await fx!.client.send(
129
+ const res = await requireFx().client.send(
125
130
  new QueryCommand({
126
- TableName: fx!.tables.ChannelMembers,
131
+ TableName: requireFx().tables.ChannelMembers,
127
132
  KeyConditionExpression: 'channelName = :k',
128
133
  ExpressionAttributeValues: { ':k': channelKey },
129
134
  }),
@@ -137,15 +142,15 @@ async function seedMembership(connId: string, nick: string, channel: ChanName):
137
142
  state.registration = 'registered';
138
143
  state.nick = nick as Nick;
139
144
  state.joinedChannels.add(channel);
140
- await fx!.client.send(
145
+ await requireFx().client.send(
141
146
  new PutCommand({
142
- TableName: fx!.tables.Connections,
147
+ TableName: requireFx().tables.Connections,
143
148
  Item: marshalConnection(state, 1_000),
144
149
  }),
145
150
  );
146
- await fx!.client.send(
151
+ await requireFx().client.send(
147
152
  new PutCommand({
148
- TableName: fx!.tables.ChannelMembers,
153
+ TableName: requireFx().tables.ChannelMembers,
149
154
  Item: marshalChannelMember(channel.toLowerCase(), {
150
155
  conn: connId,
151
156
  nick: nick as Nick,
@@ -182,8 +187,8 @@ describe.skipIf(!available)('membership transactions', () => {
182
187
 
183
188
  const row = await readConnection('c1');
184
189
  expect(row).toBeDefined();
185
- expect(row!.joinedChannels).toBeInstanceOf(Set);
186
- expect((row!.joinedChannels as Set<string>).has('#foo')).toBe(true);
190
+ expect(row?.joinedChannels).toBeInstanceOf(Set);
191
+ expect((row?.joinedChannels as Set<string>).has('#foo')).toBe(true);
187
192
  });
188
193
 
189
194
  it('PART removes both the ChannelMembers row and the channel from joinedChannels', async () => {
@@ -199,7 +204,7 @@ describe.skipIf(!available)('membership transactions', () => {
199
204
 
200
205
  const row = await readConnection('c1');
201
206
  expect(row).toBeDefined();
202
- expect((row!.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
207
+ expect((row?.joinedChannels as Set<string> | undefined)?.has('#foo') ?? false).toBe(false);
203
208
  });
204
209
 
205
210
  it('KICK (remove of a different connection) drops the target from joinedChannels', async () => {
@@ -211,7 +216,7 @@ describe.skipIf(!available)('membership transactions', () => {
211
216
  });
212
217
 
213
218
  const row = await readConnection('target');
214
- expect((row!.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
219
+ expect((row?.joinedChannels as Set<string> | undefined)?.has('#room') ?? false).toBe(false);
215
220
  expect(await readMembers('#room')).toHaveLength(0);
216
221
  });
217
222
 
@@ -234,7 +239,7 @@ describe.skipIf(!available)('membership transactions', () => {
234
239
  ]);
235
240
 
236
241
  const row = await readConnection('c1');
237
- const joined = row!.joinedChannels as Set<string>;
242
+ const joined = row?.joinedChannels as Set<string>;
238
243
  expect(joined.has('#foo')).toBe(true);
239
244
  expect(joined.has('#bar')).toBe(true);
240
245
 
@@ -260,7 +265,7 @@ describe.skipIf(!available)('membership transactions', () => {
260
265
 
261
266
  expect(await readMembers('#foo')).toHaveLength(1);
262
267
  const row = await readConnection('c1');
263
- const joined = row!.joinedChannels as Set<string>;
268
+ const joined = row?.joinedChannels as Set<string>;
264
269
  expect(joined.has('#foo')).toBe(true);
265
270
  expect([...joined].filter((c) => c === '#foo')).toHaveLength(1);
266
271
  });
@@ -283,7 +288,7 @@ describe.skipIf(!available)('membership transactions', () => {
283
288
 
284
289
  expect(await readMembers('#foo')).toHaveLength(0);
285
290
  const row = await readConnection('c1');
286
- expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
291
+ expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
287
292
  });
288
293
 
289
294
  // -------------------------------------------------------------------------
@@ -303,7 +308,7 @@ describe.skipIf(!available)('membership transactions', () => {
303
308
 
304
309
  expect(await readMembers('#foo')).toHaveLength(0);
305
310
  const row = await readConnection('c1');
306
- expect((row!.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
311
+ expect((row?.joinedChannels as Set<string> | undefined)?.size ?? 0).toBe(0);
307
312
  });
308
313
  });
309
314
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -39,9 +39,9 @@ import {
39
39
  } from '@serverless-ircd/irc-core';
40
40
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
41
41
  import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
42
- import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
43
42
  import type { PersistedConnectionState } from './serialize.js';
44
43
  import { deserialize } from './serialize.js';
44
+ import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
45
45
 
46
46
  /** Per-connection transport callbacks the ConnectionDO wires up. */
47
47
  export interface CfConnectionHandlers {
@@ -247,9 +247,7 @@ export class CfRuntime implements IrcRuntime {
247
247
  const out = new Map<ConnId, ConnectionState>();
248
248
  await Promise.all(
249
249
  memberIds.map(async (id) => {
250
- const connStub = this.env.CONNECTION_DO.get(
251
- this.env.CONNECTION_DO.idFromString(id),
252
- );
250
+ const connStub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(id));
253
251
  const dto = await (connStub as unknown as ConnectionRpc).getConnState();
254
252
  if (dto !== null) {
255
253
  out.set(id, deserialize(dto));
@@ -22,6 +22,17 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
22
22
  export interface CfConfigEnv {
23
23
  SERVER_NAME?: string;
24
24
  NETWORK_NAME?: string;
25
+ /**
26
+ * Server version surfaced in `002`/`004`/`351`/`371`. When unset the
27
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
28
+ */
29
+ SERVER_VERSION?: string;
30
+ /**
31
+ * "Created" text for `003 RPL_CREATED`. A numeric string is parsed into
32
+ * an epoch-ms number so the reducer formats it as a UTC timestamp; any
33
+ * other value is passed through verbatim.
34
+ */
35
+ CREATED_AT?: string;
25
36
  MOTD_LINES?: string;
26
37
  MAX_CLIENTS?: string;
27
38
  CHANNEL_PREFIXES?: string;
@@ -56,6 +67,12 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
56
67
  serverName: env.SERVER_NAME,
57
68
  networkName: env.NETWORK_NAME,
58
69
  };
70
+ if (env.SERVER_VERSION !== undefined) {
71
+ input.serverVersion = env.SERVER_VERSION;
72
+ }
73
+ if (env.CREATED_AT !== undefined) {
74
+ input.createdAt = parseCreatedAt(env.CREATED_AT);
75
+ }
59
76
  if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
60
77
  input.motdLines = env.MOTD_LINES.split('\n');
61
78
  }
@@ -102,6 +119,22 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
102
119
  return input;
103
120
  }
104
121
 
122
+ /**
123
+ * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
124
+ *
125
+ * A purely numeric string is treated as epoch-ms (returned as a number so
126
+ * the reducer formats it as a UTC timestamp); any other value is returned
127
+ * verbatim as a human-readable string. Empty strings fall through to the
128
+ * schema default (the caller only invokes this when the env var is set).
129
+ */
130
+ function parseCreatedAt(raw: string): string | number {
131
+ if (/^\d+$/.test(raw.trim())) {
132
+ const n = Number(raw.trim());
133
+ if (Number.isFinite(n)) return n;
134
+ }
135
+ return raw;
136
+ }
137
+
105
138
  /**
106
139
  * Parses a newline-delimited `username:password` string into the config's
107
140
  * `saslAccounts` shape. Malformed entries are skipped so a trailing
@@ -6,8 +6,8 @@
6
6
  * `HibernatingWebSocketHandler` so idle WebSocket connections cost
7
7
  * nothing between messages. When a message arrives the DO wakes, replays
8
8
  * the persisted {@link ConnectionState} from `state.storage`, runs the
9
- * frame through {@link ConnectionActor} with a stub {@link CfRuntime},
10
- * and persists the updated state back.
9
+ * frame through {@link ConnectionActor} with the {@link CfRuntime}, and
10
+ * persists the updated state back.
11
11
  *
12
12
  * Lifecycle:
13
13
  * - `fetch()` WebSocket upgrade; `acceptWebSocket`.
@@ -16,15 +16,16 @@
16
16
  * - `alarm()` PING sweep; missing PONG disconnects.
17
17
  *
18
18
  * Cross-DO coordination:
19
- * - `REGISTRY_DO` (RPC stub today, real in 034).
20
- * - `CHANNEL_DO` per lowercased channel (stub today, real in 035).
19
+ * - `REGISTRY_DO` sharded nick registry (034).
20
+ * - `CHANNEL_DO` per lowercased channel authoritative roster/modes
21
+ * (035).
21
22
  *
22
23
  * The actor's `ActorChannelAccess` is a passthrough that returns
23
24
  * throwaway {@link ChannelState} objects — reducers mutate the local
24
25
  * view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
25
- * to the authoritative ChannelDO. Sufficient for connection-authority
26
- * commands and JOIN/QUIT fan-out; richer channel reads (NAMES of remote
27
- * state) ship in 036.
26
+ * to the authoritative ChannelDO. Connection-authority commands and
27
+ * JOIN/QUIT fan-out are fully wired; richer channel reads (NAMES of
28
+ * remote state) are served via the ChannelDO snapshot RPC.
28
29
  */
29
30
 
30
31
  import { DurableObject } from 'cloudflare:workers';
@@ -37,18 +38,20 @@ import {
37
38
  type ConnectionState,
38
39
  ConsoleLogger,
39
40
  type IdFactory,
40
- InMemoryAccountStore,
41
41
  InMemoryMessageStore,
42
+ InMemoryMtlsIdentityProvider,
43
+ InMemoryNickHistoryStore,
42
44
  LogLevel,
43
45
  type Logger,
44
46
  type MessageStore,
45
47
  type MotdProvider,
48
+ type MtlsIdentityProvider,
49
+ type NickHistoryStore,
46
50
  type RawLine,
47
51
  type SaslAccountCredential,
48
52
  type ServerConfig,
49
53
  SystemClock,
50
54
  UuidIdFactory,
51
- applyChannelDelta,
52
55
  createChannel,
53
56
  createConnection,
54
57
  toSnapshot,
@@ -56,8 +59,9 @@ import {
56
59
  import { ConnectionActor } from '@serverless-ircd/irc-server';
57
60
  import { makeCfRuntime } from './cf-runtime.js';
58
61
  import type { CfConnectionHandlers } from './cf-runtime.js';
62
+ import { resolveAccountStore } from './d1-account-store.js';
59
63
  import type { Env } from './env.js';
60
- import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
64
+ import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
61
65
  import type { PersistedConnectionState } from './serialize.js';
62
66
 
63
67
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
@@ -109,18 +113,52 @@ export class ConnectionDO extends DurableObject<Env> {
109
113
  */
110
114
  private messages: MessageStore | undefined;
111
115
  /**
112
- * In-worker SASL account store seeded from the `SASL_ACCOUNTS` env var.
113
- * Lazily constructed so the first frame that needs it parses the env once.
114
- * A Durable-Object / D1-backed persistent variant is a documented
115
- * follow-up; swapping it in means replacing {@link accountStore}.
116
+ * Per-connection nick-history store backing `WHOWAS`. The minimum-viable
117
+ * in-worker ring buffer is scoped to this connection: sign-offs this
118
+ * connection records are queryable by the same connection. A
119
+ * Durable-Object-backed persistent variant (shared across connections,
120
+ * keyed by nick) is a documented follow-up. Lazily constructed.
121
+ */
122
+ private history: NickHistoryStore | undefined;
123
+ /**
124
+ * SASL account store, resolved once via {@link loadAccountStore} before the
125
+ * first frame dispatches. The two-phase load mirrors AWS's
126
+ * `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
127
+ * rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
128
+ * env-var seed provides the fallback `InMemoryAccountStore`. The
129
+ * `AccountStore.verify` port is synchronous, so pre-loading MUST complete
130
+ * before the first reducer runs.
116
131
  */
117
132
  private accounts: AccountStore | undefined;
133
+ /** Guards {@link loadAccountStore} so the D1 query runs at most once. */
134
+ private accountsResolved = false;
135
+ /**
136
+ * Verified client-cert subject captured at WebSocket upgrade time when
137
+ * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
138
+ * via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
139
+ * the cert subject to an account.
140
+ *
141
+ * CF API Shield mTLS setup:
142
+ * 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
143
+ * 2. Create an mTLS policy on the custom hostname that requires client certs.
144
+ * 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
145
+ * on every request, which the Worker reads at upgrade time.
146
+ * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
147
+ */
148
+ private mtlsCertSubject: string | undefined;
118
149
  private readonly clock: Clock = SystemClock;
119
150
  private readonly ids: IdFactory = new UuidIdFactory();
120
151
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
121
152
  private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
122
- /** Outstanding outbound lines, drained when the WS sends complete. */
123
- // (Reserved for a future batching optimization; kept off until 036.)
153
+ // Outbound lines are batched per call, not via a reserved instance
154
+ // field: `deliver()` joins every line of a ChannelDO broadcast into a
155
+ // single `WebSocket.send()`, and the actor `send` handler in
156
+ // `buildActor` does the same for one frame's response. Cross-call
157
+ // per-microtask coalescing (the optimization previously reserved here)
158
+ // was dropped — it would add a drain queue and ordering hazards between
159
+ // independent fan-out calls for no measured benefit (see ADR-003 and
160
+ // the CF adapter load-test report). Revisit only if WS round-trips
161
+ // become a demonstrated bottleneck.
124
162
 
125
163
  // -------------------------------------------------------------------------
126
164
  // WebSocket upgrade
@@ -134,6 +172,14 @@ export class ConnectionDO extends DurableObject<Env> {
134
172
  const pair = new WebSocketPair();
135
173
  const server = pair[0];
136
174
  const client = pair[1];
175
+ // Capture the verified client-cert subject when CF API Shield mTLS is
176
+ // active. `request.cf` is a Cloudflare-specific extension; the property
177
+ // is present only when a client certificate was verified against the
178
+ // uploaded CA pool.
179
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
180
+ if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
181
+ this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
182
+ }
137
183
  // Hibernation: `acceptWebSocket` accepts the server side implicitly.
138
184
  // Calling `server.accept()` first throws "already accepted".
139
185
  this.ctx.acceptWebSocket(server);
@@ -150,9 +196,9 @@ export class ConnectionDO extends DurableObject<Env> {
150
196
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
151
197
  const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
152
198
  const state = await this.loadState();
153
- const before = new Set(state.joinedChannels);
154
199
  const beforeNick = state.nick;
155
200
 
201
+ await this.loadAccountStore();
156
202
  const actor = this.buildActor(ws, state);
157
203
  try {
158
204
  await actor.receiveTextFrame(text);
@@ -165,17 +211,11 @@ export class ConnectionDO extends DurableObject<Env> {
165
211
  if (beforeNick === undefined && state.nick !== undefined) {
166
212
  await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
167
213
  }
168
- // Detect newly joined channels so we can register them with the
169
- // channel DO necessary for the close handler to fan out QUIT to
170
- // them. The stub channel DO records the call; the real ChannelDO
171
- // (035) maintains authoritative roster state.
172
- for (const chan of state.joinedChannels) {
173
- if (!before.has(chan)) {
174
- // joinReducer already emitted an ApplyChannelDelta; the stub
175
- // ChannelDO records it. No extra work needed here for 033.
176
- void chan;
177
- }
178
- }
214
+ // No per-ConnectionDO channel-registration step is needed here: channel
215
+ // membership is driven solely by the `ApplyChannelDelta` effect the
216
+ // joinReducer emits, which the CfRuntime forwards to the authoritative
217
+ // ChannelDO. The ChannelDO roster IS the fan-out registration target, so
218
+ // a second loop over newly joined channels here would be redundant.
179
219
  }
180
220
 
181
221
  /** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
@@ -310,9 +350,9 @@ export class ConnectionDO extends DurableObject<Env> {
310
350
  },
311
351
  snapshot: (): ConnectionState | undefined => this.cached,
312
352
  };
313
- // For 033 stub routing we look up the per-connection registry
314
- // stub by the DO instance's hex id. Production (034) shards the
315
- // registry by nick instead, at which point `registryKey` is unused.
353
+ // The CfRuntime shards the registry by nick (034); the per-connection
354
+ // key passed here is ignored by `makeCfRuntime` (kept in the signature
355
+ // for call-site stability).
316
356
  const registryKey = this.ctx.id.toString();
317
357
  const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
318
358
  if (this.channelAccess === undefined) {
@@ -323,6 +363,10 @@ export class ConnectionDO extends DurableObject<Env> {
323
363
  // per-connection in the Workers dashboard.
324
364
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
325
365
  const accounts = this.accountStore();
366
+ const mtlsIdentity: MtlsIdentityProvider | undefined =
367
+ this.mtlsCertSubject !== undefined
368
+ ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
369
+ : undefined;
326
370
  return new ConnectionActor({
327
371
  state,
328
372
  runtime,
@@ -332,7 +376,9 @@ export class ConnectionDO extends DurableObject<Env> {
332
376
  ids: this.ids,
333
377
  motd,
334
378
  messages: this.messageStore(),
379
+ history: this.historyStore(),
335
380
  ...(accounts !== undefined ? { accounts } : {}),
381
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
336
382
  logger,
337
383
  });
338
384
  }
@@ -340,6 +386,7 @@ export class ConnectionDO extends DurableObject<Env> {
340
386
  /** Tears down a connection: emit QUIT effects, release nick, close WS. */
341
387
  private async tearDown(ws: WebSocket): Promise<void> {
342
388
  const state = await this.loadState();
389
+ await this.loadAccountStore();
343
390
  const actor = this.buildActor(ws, state);
344
391
  // Drive QUIT through the actor so the same effect pipeline handles
345
392
  // fanout as during normal operation.
@@ -372,6 +419,8 @@ export class ConnectionDO extends DurableObject<Env> {
372
419
  ...DEFAULT_SERVER_CONFIG,
373
420
  ...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
374
421
  ...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
422
+ ...(this.env.SERVER_VERSION !== undefined ? { serverVersion: this.env.SERVER_VERSION } : {}),
423
+ ...(this.env.CREATED_AT !== undefined ? { createdAt: this.env.CREATED_AT } : {}),
375
424
  };
376
425
  }
377
426
 
@@ -398,17 +447,37 @@ export class ConnectionDO extends DurableObject<Env> {
398
447
  }
399
448
 
400
449
  /**
401
- * Lazily constructs the in-worker SASL account store from the
402
- * `SASL_ACCOUNTS` env var. Returns `undefined` when no accounts are
403
- * configured so the actor's `ctx.accounts` stays unset (preserving the
404
- * no-store behaviour: `AUTHENTICATE PLAIN` → `904`). A Durable-Object /
405
- * D1-backed persistent variant is a documented follow-up.
450
+ * Lazily constructs the per-connection nick-history store. A future
451
+ * persistent variant (DO/KV-backed, keyed by nick) will replace this
452
+ * without touching the actor wiring.
453
+ */
454
+ private historyStore(): NickHistoryStore {
455
+ if (this.history === undefined) {
456
+ this.history = new InMemoryNickHistoryStore(this.clock);
457
+ }
458
+ return this.history;
459
+ }
460
+
461
+ /**
462
+ * Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
463
+ * caching the result. Safe to call on every frame; the D1 query runs at
464
+ * most once per ConnectionDO instance. Must complete before the first
465
+ * reducer dispatches so the synchronous `AccountStore.verify` port has its
466
+ * data ready.
467
+ */
468
+ private async loadAccountStore(): Promise<void> {
469
+ if (this.accountsResolved) return;
470
+ this.accountsResolved = true;
471
+ this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
472
+ }
473
+
474
+ /**
475
+ * Returns the resolved account store, or `undefined` when no accounts are
476
+ * configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
477
+ * PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
478
+ * `await loadAccountStore()` before reading this.
406
479
  */
407
480
  private accountStore(): AccountStore | undefined {
408
- if (this.accounts !== undefined) return this.accounts;
409
- const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
410
- if (creds.length === 0) return undefined;
411
- this.accounts = new InMemoryAccountStore(creds);
412
481
  return this.accounts;
413
482
  }
414
483
 
@@ -437,6 +506,7 @@ export class ConnectionDO extends DurableObject<Env> {
437
506
  private async tearDownNoSocket(state: ConnectionState): Promise<void> {
438
507
  // Emit QUIT effects without dispatching transport calls (the socket
439
508
  // is already gone). The cross-DO fanout still runs.
509
+ await this.loadAccountStore();
440
510
  const noOpHandlers: CfConnectionHandlers = {
441
511
  send: (): void => {},
442
512
  disconnect: (): void => {},
@@ -456,6 +526,7 @@ export class ConnectionDO extends DurableObject<Env> {
456
526
  ids: this.ids,
457
527
  motd: this.motdProvider(),
458
528
  messages: this.messageStore(),
529
+ history: this.historyStore(),
459
530
  });
460
531
  await actor.receiveTextFrame('QUIT\r\n');
461
532
  await this.releaseAndPersist(state);
@@ -629,12 +700,6 @@ class PassthroughChannelAccess {
629
700
  }
630
701
  }
631
702
 
632
- // Reference `applyChannelDelta` to ensure the import is used downstream by
633
- // tests that exercise state migrations; harmless if the symbol is unused
634
- // in production today. (Removed from the ConnectionDO path because the
635
- // authoritative delta application lives in the ChannelDO stub today.)
636
- export { applyChannelDelta, PERSISTED_STATE_VERSION };
637
-
638
703
  /**
639
704
  * Parses the `SASL_ACCOUNTS` env var into credential entries.
640
705
  *