serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -13,15 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
- import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
16
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
17
17
  import {
18
18
  type ParsedServerConfig,
19
+ WS_SUBPROTO_BINARY,
19
20
  type WsSubprotocol,
20
21
  parseSecWsProtocolOffers,
21
22
  selectSubprotocol,
22
23
  wsFrameModeFor,
23
24
  } from '@serverless-ircd/irc-core';
24
25
  import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
26
+ import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
25
27
  import { marshalConnection } from '../serialize.js';
26
28
  import type { TablesConfig } from '../tables.js';
27
29
  import { createInitialConnectionState } from './state.js';
@@ -58,74 +60,76 @@ export type ConnectOutcome =
58
60
  * Enforces the `maxClients` admission cap, then inserts the Connections
59
61
  * row for a fresh WebSocket upgrade.
60
62
  *
63
+ * Admission is O(1): instead of a full-table `Scan COUNT` (which made
64
+ * `$connect` latency scale with the live connection count), the live count
65
+ * is kept in a single meta row and atomically incremented here. The
66
+ * reservation is rolled back if admission rejects or the row write fails,
67
+ * so the counter stays in sync with the real connection set across
68
+ * `$connect` / `$disconnect` / the gone-connection sweeper.
69
+ *
61
70
  * Idempotent on success: APIGW retries `$connect` on transient failures,
62
71
  * so the write uses `PutCommand` (overwrites) rather than a conditional
63
- * insert.
64
- *
65
- * TOCTOU note: DynamoDB cannot conditionally write based on a row count,
66
- * so the count-then-put sequence has a race window. This is an accepted
67
- * trade-off for an admission cap — it bounds growth under steady load
68
- * rather than enforcing a hard invariant. Two concurrent upgrades that
69
- * both observe `count == cap - 1` will both be admitted.
72
+ * insert. A retry re-runs the increment; the matching decrement fires when
73
+ * the connection eventually tears down, so the long-run counter stays
74
+ * correct (each live connection contributes exactly one reserved slot).
70
75
  *
71
- * Per-IP admission (`maxConnectionsPerIp`) is intentionally NOT enforced
72
- * here. irc-core's in-memory `AdmissionStats` is unsuitable for Lambda
73
- * (each invocation is a fresh process with no shared counters), and a
74
- * filtered full-table `Scan` per `$connect` would double the DynamoDB
75
- * cost on every connect (and `maxConnectionsPerIp` is on by default).
76
- * The correct fix is to persist `sourceIp` on the Connections row and
77
- * add a DynamoDB GSI keyed by `sourceIp`, then feed the GSI's count
78
- * into {@link decideConnectAdmission}'s `perIp` branch. That schema
79
- * change is tracked as a follow-up; the pure policy already supports it.
76
+ * TOCTOU note: two concurrent `$connect` invocations can both reserve a
77
+ * slot before either decrements; the cap is therefore best-effort (it
78
+ * bounds growth under steady load rather than enforcing a hard invariant).
79
+ * This is no worse than the prior count-then-put sequence, and the
80
+ * per-connection cost dropped from O(connections) to O(1).
80
81
  */
81
82
  export async function handleConnect(params: ConnectParams): Promise<ConnectOutcome> {
82
83
  const now = params.now ?? Date.now();
83
- const total = await countConnections(params.dynamo, params.tables.Connections);
84
- const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
85
- if (!outcome.admitted) return outcome;
84
+ const maxClients = params.serverConfig.maxClients;
85
+
86
+ // Reserve a slot atomically. The returned value includes this reservation,
87
+ // so the pre-existing count (`total`, as expected by the policy) is one
88
+ // less than the post-increment value.
89
+ const reservedCount = await incrementConnectionCount(params.dynamo, params.tables.Connections);
90
+ const outcome = decideConnectAdmission({ total: reservedCount - 1 }, { maxClients });
91
+ if (!outcome.admitted) {
92
+ // Over capacity — release the reservation so the counter does not drift
93
+ // upward on every rejected connect.
94
+ await decrementConnectionCount(params.dynamo, params.tables.Connections);
95
+ return outcome;
96
+ }
86
97
  // IRCv3 WebSocket subprotocol negotiation: parse the offered
87
98
  // `Sec-WebSocket-Protocol` list, select the first supported entry, and
88
99
  // persist the resulting frame mode so every subsequent `$default`
89
100
  // invocation recovers it (a Lambda `$connect` has no live socket to
90
101
  // tag, unlike the CF adapter's hibernation tags). `null` means no
91
102
  // supported subprotocol was offered → legacy framing, no mode column.
92
- const chosen = selectSubprotocol(parseSecWsProtocolOffers(params.secWebSocketProtocol));
103
+ //
104
+ // API Gateway carries TEXT WebSocket frames only — binary frames are
105
+ // unsupported, and agreeing to `binary.ircv3.net` makes APIGW close the
106
+ // connection with code 1003 ("Binary is not supported") the instant it
107
+ // is negotiated. The IRCv3 relay (tcp-ws-forwarder) offers binary first
108
+ // by preference, so the generic core selector would agree to binary and
109
+ // the connection is torn down immediately. Strip binary from the offer
110
+ // list before selecting so only `text.ircv3.net` (or legacy fallback
111
+ // when only binary was offered) is ever negotiated on this transport.
112
+ const chosen = selectSubprotocol(
113
+ parseSecWsProtocolOffers(params.secWebSocketProtocol).filter((o) => o !== WS_SUBPROTO_BINARY),
114
+ );
93
115
  const mode = wsFrameModeFor(chosen ?? undefined);
94
116
  const state = createInitialConnectionState(params.connectionId, now);
95
117
  const row = marshalConnection(state, now);
96
118
  if (mode !== 'legacy') {
97
119
  row.wsMode = mode;
98
120
  }
99
- await params.dynamo.send(
100
- new PutCommand({
101
- TableName: params.tables.Connections,
102
- Item: row,
103
- }),
104
- );
105
- return { admitted: true, subprotocol: chosen };
106
- }
107
-
108
- /**
109
- * Paginated `Scan` with `Select: COUNT` over the `Connections` table.
110
- * A single Scan page caps at 1MB, so the loop follows
111
- * `LastEvaluatedKey` until every page has been summed.
112
- */
113
- async function countConnections(
114
- dynamo: DynamoDBDocumentClient,
115
- tableName: string,
116
- ): Promise<number> {
117
- let total = 0;
118
- let lastKey: unknown = undefined;
119
- do {
120
- const res = await dynamo.send(
121
- new ScanCommand({
122
- TableName: tableName,
123
- Select: 'COUNT',
124
- ...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
121
+ try {
122
+ await params.dynamo.send(
123
+ new PutCommand({
124
+ TableName: params.tables.Connections,
125
+ Item: row,
125
126
  }),
126
127
  );
127
- total += res.Count ?? 0;
128
- lastKey = res.LastEvaluatedKey;
129
- } while (lastKey !== undefined);
130
- return total;
128
+ } catch (err) {
129
+ // Row write failed — release the reservation so a failed `Put` does not
130
+ // leave an inflated counter that tightens the cap on every retry.
131
+ await decrementConnectionCount(params.dynamo, params.tables.Connections);
132
+ throw err;
133
+ }
134
+ return { admitted: true, subprotocol: chosen };
131
135
  }
@@ -31,6 +31,7 @@ import {
31
31
  } from '@serverless-ircd/irc-core';
32
32
  import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
33
33
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
34
+ import type { DynamoServicesStore } from '../dynamo-services-store.js';
34
35
  import {
35
36
  CONNECTION_VERSION,
36
37
  type MarshalledConnection,
@@ -68,6 +69,15 @@ export interface DefaultParams {
68
69
  * — preserves the behaviour of callers that have not opted in.
69
70
  */
70
71
  accounts?: AccountStore;
72
+ /**
73
+ * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
74
+ * OperServ + read-marker). When omitted the actor runs with
75
+ * `ctx.services` undefined so `PRIVMSG NickServ` / ChanServ routing is
76
+ * disabled — preserves the behaviour of callers that have not opted in.
77
+ * On success the handler flushes pending write-behind ops after the
78
+ * actor run.
79
+ */
80
+ services?: DynamoServicesStore;
71
81
  /**
72
82
  * mTLS client-cert identity source for SASL EXTERNAL. When omitted the
73
83
  * actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
@@ -118,14 +128,25 @@ export interface DefaultParams {
118
128
  export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
119
129
  const clock = params.clock ?? SystemClock;
120
130
  const ids = params.ids ?? new UuidIdFactory();
121
- // Load persisted state.
131
+ // Load persisted state. Strongly consistent: $connect writes this row and
132
+ // $default reads it on the very next frame — an eventually-consistent Get
133
+ // can miss the just-written row (DynamoDB replication lag) and silently
134
+ // drop the client's first frame as a 410. Strong consistency closes that
135
+ // read-after-write window.
122
136
  const result = await params.dynamo.send(
123
137
  new GetCommand({
124
138
  TableName: params.tables.Connections,
125
139
  Key: { connectionId: params.connectionId },
140
+ ConsistentRead: true,
126
141
  }),
127
142
  );
128
143
  if (result.Item === undefined) {
144
+ // Previously this branch returned silently, making a missing-row
145
+ // $default invisible to operators (client sees nothing, logs show
146
+ // nothing). Warn so the 410 path is diagnosable.
147
+ console.warn(
148
+ `[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
149
+ );
129
150
  return { statusCode: 410 };
130
151
  }
131
152
  const persisted = result.Item as unknown as MarshalledConnection;
@@ -192,6 +213,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
192
213
  }),
193
214
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
194
215
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
216
+ ...(params.services !== undefined ? { services: params.services } : {}),
195
217
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
196
218
  ...(params.history !== undefined ? { history: params.history } : {}),
197
219
  // API Gateway terminates TLS before the Lambda is invoked, so every
@@ -205,11 +227,15 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
205
227
  console.error('[irc-handler] actor.receiveTextFrame failed', err);
206
228
  // Still persist the state — partial mutations are valuable for debug.
207
229
  await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
230
+ await params.services?.flush();
208
231
  return { statusCode: 500 };
209
232
  }
210
233
 
211
234
  // Persist the mutated state and flush outbound bytes.
212
235
  await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
236
+ // Drain any services write-behind ops (NickServ/ChanServ/...) so a
237
+ // Lambda freeze / evict does not lose state.
238
+ await params.services?.flush();
213
239
 
214
240
  // In production, deliver outbound bytes back to the caller via APIGW.
215
241
  // Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
@@ -218,9 +244,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
218
244
  if (params.managementApi !== null && outbound.length > 0) {
219
245
  try {
220
246
  await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
221
- } catch {
222
- // The handler still returns 200 the actor's mutations are
223
- // persisted; the caller just didn't see the reply this frame.
247
+ } catch (err) {
248
+ // Surface the failure (gone connection, stale MANAGEMENT_URL, …)
249
+ // so it shows in CloudWatch instead of vanishing. The handler still
250
+ // returns 200 — the actor's mutations are persisted; the caller just
251
+ // didn't see the reply this frame.
252
+ console.error('[irc-handler] postToConnection failed', err);
224
253
  }
225
254
  }
226
255
  return { statusCode: 200 };
@@ -382,6 +411,9 @@ class LambdaChannelAccess implements ActorChannelAccess {
382
411
  moderated: false,
383
412
  secret: false,
384
413
  private: false,
414
+ registered: false,
415
+ blockUnidentified: false,
416
+ moderatedIdent: false,
385
417
  },
386
418
  banMasks: new Set<string>(),
387
419
  members: new Map(),
@@ -27,6 +27,7 @@ import {
27
27
  } from '@serverless-ircd/irc-core';
28
28
  import { resolveAccountStore } from '../account-store.js';
29
29
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
30
+ import { type DynamoServicesStore, loadDynamoServicesStore } from '../dynamo-services-store.js';
30
31
  import { createDynamoDocumentClient } from '../dynamo.js';
31
32
  import { bindMessageStore } from '../message-store.js';
32
33
  import { TABLE_KEYS, type TablesConfig } from '../tables.js';
@@ -111,6 +112,15 @@ export interface HandlerDeps {
111
112
  * configured (the default) so `ctx.accounts` stays unset.
112
113
  */
113
114
  accounts?: AccountStore;
115
+ /**
116
+ * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
117
+ * OperServ + read-marker), hydrated once per cold start from the
118
+ * `Services` DynamoDB table into a write-behind cache. `undefined` when
119
+ * `SERVICES_TABLE` is unset (services unbound → no NickServ/ChanServ
120
+ * routing). The handler flushes pending writes after each `$default` /
121
+ * NLB frame so a Lambda freeze / evict does not lose services state.
122
+ */
123
+ services?: DynamoServicesStore;
114
124
  /**
115
125
  * Nick-history source bound to every actor. Constructed once per cold
116
126
  * start (memoised via {@link cachedDeps}) as an in-memory store so it
@@ -224,6 +234,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
224
234
  motd: deps.motd,
225
235
  messages: deps.messages,
226
236
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
237
+ ...(deps.services !== undefined ? { services: deps.services } : {}),
227
238
  ...(deps.history !== undefined ? { history: deps.history } : {}),
228
239
  ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
229
240
  managementApi: deps.managementApi,
@@ -331,6 +342,7 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
331
342
  messages: deps.messages,
332
343
  managementApi: deps.managementApi,
333
344
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
345
+ ...(deps.services !== undefined ? { services: deps.services } : {}),
334
346
  ...(deps.history !== undefined ? { history: deps.history } : {}),
335
347
  });
336
348
  }
@@ -367,6 +379,8 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
367
379
  const messages = bindMessageStore(cfg);
368
380
  const history = new InMemoryNickHistoryStore(SystemClock);
369
381
  const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
382
+ const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
383
+ const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
370
384
  // REHASH reload source: re-read the live env so a rotated oper password
371
385
  // or MOTD takes effect on the next REHASH without a redeploy.
372
386
  const configLoader = async (): Promise<ServerConfig> =>
@@ -381,6 +395,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
381
395
  history,
382
396
  configLoader,
383
397
  ...(accounts !== undefined ? { accounts } : {}),
398
+ ...(services !== undefined ? { services } : {}),
384
399
  };
385
400
  }
386
401
 
@@ -64,6 +64,7 @@ import {
64
64
  } from '@serverless-ircd/irc-server';
65
65
  import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
66
66
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
67
+ import type { DynamoServicesStore } from '../dynamo-services-store.js';
67
68
  import type { MarshalledConnection } from '../serialize.js';
68
69
  import { CONNECTION_VERSION } from '../serialize.js';
69
70
  import type { TablesConfig } from '../tables.js';
@@ -138,6 +139,12 @@ export interface NlbStreamParams {
138
139
  messages?: MessageStore;
139
140
  /** SASL account verification source. */
140
141
  accounts?: AccountStore;
142
+ /**
143
+ * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
144
+ * OperServ + read-marker). Flushed after each frame so a Lambda freeze
145
+ * does not lose services state.
146
+ */
147
+ services?: DynamoServicesStore;
141
148
  /** mTLS identity source for SASL EXTERNAL. */
142
149
  mtlsIdentity?: import('@serverless-ircd/irc-core').MtlsIdentityProvider;
143
150
  /** Nick-history source for WHOWAS. */
@@ -258,6 +265,7 @@ export async function handleNlbStream(
258
265
  transport: new WsTextFrameTransport(),
259
266
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
260
267
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
268
+ ...(params.services !== undefined ? { services: params.services } : {}),
261
269
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
262
270
  ...(params.history !== undefined ? { history: params.history } : {}),
263
271
  // Surface TLS fact when the NLB listener terminates TLS (irc+tls port).
@@ -282,6 +290,10 @@ export async function handleNlbStream(
282
290
  newBuffer,
283
291
  );
284
292
 
293
+ // Drain any services write-behind ops so a Lambda freeze / evict does
294
+ // not lose NickServ/ChanServ/... state.
295
+ await params.services?.flush();
296
+
285
297
  // 7. Return outbound bytes as the NLB response body.
286
298
  const responseBody = outbound.length > 0 ? `${outbound.join('\r\n')}\r\n` : '';
287
299
  return {
@@ -309,14 +321,22 @@ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
309
321
  return undefined;
310
322
  }
311
323
 
312
- /** Loads a raw Connections row (includes transportBuffer if present). */
324
+ /**
325
+ * Loads a raw Connections row (includes transportBuffer if present).
326
+ *
327
+ * Strongly consistent: the NLB handler has no `$connect` event — it writes
328
+ * the row itself on the first chunk and reads it back on the very next
329
+ * invocation. An eventually-consistent Get can miss that just-written row
330
+ * under DynamoDB replication lag and drop the first frame(s) of a
331
+ * raw-IRC-over-`:6697` connection.
332
+ */
313
333
  async function loadRow(
314
334
  dynamo: DynamoDBDocumentClient,
315
335
  tableName: string,
316
336
  connId: string,
317
337
  ): Promise<RawConnectionRow | null> {
318
338
  const result = await dynamo.send(
319
- new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
339
+ new GetCommand({ TableName: tableName, Key: { connectionId: connId }, ConsistentRead: true }),
320
340
  );
321
341
  if (result.Item === undefined) return null;
322
342
  return result.Item as unknown as RawConnectionRow;
@@ -460,6 +480,9 @@ class NlbChannelAccess implements ActorChannelAccess {
460
480
  moderated: false,
461
481
  secret: false,
462
482
  private: false,
483
+ registered: false,
484
+ blockUnidentified: false,
485
+ moderatedIdent: false,
463
486
  },
464
487
  banMasks: new Set<string>(),
465
488
  members: new Map(),
@@ -19,6 +19,7 @@
19
19
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
20
20
  import { ScanCommand } from '@aws-sdk/lib-dynamodb';
21
21
  import { cleanupConnection } from '../aws-runtime.js';
22
+ import { CONNECTION_COUNT_META_ID } from '../connection-counter.js';
22
23
  import type { TablesConfig } from '../tables.js';
23
24
 
24
25
  /**
@@ -69,8 +70,11 @@ export async function handleSweep(params: SweepParams): Promise<SweepResult> {
69
70
  );
70
71
  lastKey = res.LastEvaluatedKey;
71
72
  for (const item of res.Items ?? []) {
72
- scanned++;
73
73
  const row = item as { connectionId?: string; idleSince?: number };
74
+ // Skip the admission-counter meta row (it carries no idleSince and is
75
+ // never a real connection to tear down).
76
+ if (row.connectionId === CONNECTION_COUNT_META_ID) continue;
77
+ scanned++;
74
78
  const idleSince = row.idleSince;
75
79
  if (typeof idleSince !== 'number' || idleSince >= cutoff) continue;
76
80
  const connId = row.connectionId;
@@ -23,6 +23,10 @@ export {
23
23
  loadDynamoAccountStore,
24
24
  } from './dynamo-account-store.js';
25
25
  export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
26
+ export {
27
+ DynamoServicesStore,
28
+ loadDynamoServicesStore,
29
+ } from './dynamo-services-store.js';
26
30
  export { AwsStats } from './stats.js';
27
31
  export type { AwsStatsOptions } from './stats.js';
28
32
  export {
@@ -17,6 +17,7 @@
17
17
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
18
18
  import { ScanCommand } from '@aws-sdk/lib-dynamodb';
19
19
  import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
20
+ import { CONNECTION_COUNT_META_ID } from './connection-counter.js';
20
21
  import type { MarshalledConnection } from './serialize.js';
21
22
  import { unmarshalConnection } from './serialize.js';
22
23
  import type { TablesConfig } from './tables.js';
@@ -63,7 +64,11 @@ export class AwsStats {
63
64
  new ScanCommand({ TableName: this.tables.Connections }),
64
65
  );
65
66
  const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
66
- const connections = connItems.map(unmarshalConnection);
67
+ // The admission counter meta row lives in this table (keyed by the
68
+ // sentinel id); exclude it so LUSERS/STATS report real connections only.
69
+ const connections = connItems
70
+ .filter((item) => item.connectionId !== CONNECTION_COUNT_META_ID)
71
+ .map(unmarshalConnection);
67
72
 
68
73
  // Channel count: one row per channel in `ChannelMeta`, again in a
69
74
  // single page (channel counts are small).
@@ -14,7 +14,13 @@
14
14
  */
15
15
 
16
16
  /** Logical table names — also used as CDK construct ids. */
17
- export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Accounts';
17
+ export type TableName =
18
+ | 'Connections'
19
+ | 'ChannelMeta'
20
+ | 'ChannelMembers'
21
+ | 'Nicks'
22
+ | 'Accounts'
23
+ | 'Services';
18
24
 
19
25
  /** Logical table names in stable order. */
20
26
  export const TABLE_NAMES: readonly TableName[] = [
@@ -23,9 +29,19 @@ export const TABLE_NAMES: readonly TableName[] = [
23
29
  'ChannelMembers',
24
30
  'Nicks',
25
31
  'Accounts',
32
+ 'Services',
26
33
  ];
27
34
 
28
- /** Key column names used throughout the runtime (avoids stringly-typed typos). */
35
+ /**
36
+ * Key column names used throughout the runtime (avoids stringly-typed typos).
37
+ *
38
+ * `Services` is intentionally absent: the services table is provisioned +
39
+ * env-injected alongside the others (via {@link TABLE_DEFS} in
40
+ * `cdk-table-defs.ts`) but the runtime reads `SERVICES_TABLE` directly in
41
+ * `buildDepsFromEnv` — services persistence is optional, so it must not be
42
+ * part of the required `tablesConfigFromEnv` loop (a legacy deploy without
43
+ * the table still boots, just with services unbound).
44
+ */
29
45
  export const TABLE_KEYS = {
30
46
  Connections: { pk: 'connectionId' },
31
47
  ChannelMeta: { pk: 'channelName' },
@@ -38,8 +54,20 @@ export const TABLE_KEYS = {
38
54
  * Maps logical names to physical table names. Production uses the bare
39
55
  * names; tests override with a unique prefix per harness to isolate
40
56
  * parallel runs against a shared DynamoDB Local instance.
57
+ *
58
+ * `Services` is optional: the services-persistence table is provisioned by
59
+ * {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
60
+ * tolerates an unset var (services unbound). A `TablesConfig` built from
61
+ * the required-five env vars still type-checks.
41
62
  */
42
- export type TablesConfig = Record<TableName, string>;
63
+ export type TablesConfig = {
64
+ Connections: string;
65
+ ChannelMeta: string;
66
+ ChannelMembers: string;
67
+ Nicks: string;
68
+ Accounts: string;
69
+ Services?: string;
70
+ };
43
71
 
44
72
  /** Builds a {@link TablesConfig} from the supplied physical table names. */
45
73
  export function tablesConfigFromNames(names: {
@@ -48,6 +76,8 @@ export function tablesConfigFromNames(names: {
48
76
  ChannelMembers: string;
49
77
  Nicks: string;
50
78
  Accounts: string;
79
+ Services?: string;
51
80
  }): TablesConfig {
52
- return { ...names };
81
+ const out: TablesConfig = { ...names };
82
+ return out;
53
83
  }
@@ -277,6 +277,9 @@ class AwsChannelAccess implements ActorChannelAccess {
277
277
  moderated: false,
278
278
  secret: false,
279
279
  private: false,
280
+ registered: false,
281
+ blockUnidentified: false,
282
+ moderatedIdent: false,
280
283
  },
281
284
  banMasks: new Set<string>(),
282
285
  members: new Map(),
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
+ import { GetCommand } from '@aws-sdk/lib-dynamodb';
10
11
  import {
11
12
  type ChannelDelta,
12
13
  type Clock,
@@ -15,7 +16,7 @@ import {
15
16
  type ServerConfig,
16
17
  createConnection,
17
18
  } from '@serverless-ircd/irc-core';
18
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
20
  import { AwsRuntime } from '../src/aws-runtime.js';
20
21
  import type { AwsRuntimeHandlers, PostToConnection } from '../src/aws-runtime.js';
21
22
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -235,6 +236,27 @@ describe.skipIf(!available)('AwsRuntime', () => {
235
236
  const got = await rt.getConnection('self');
236
237
  expect(got).toBe(state);
237
238
  });
239
+
240
+ it('getConnectionInfo reads a peer Connections row strongly consistently', async () => {
241
+ // Peer lookups (WHOIS, roster hydration via readConnectionRow) read
242
+ // another connection's row. Strong consistency avoids presenting a
243
+ // stale peer snapshot under DynamoDB replication lag.
244
+ const rt = makeRuntime('self', noopHandlers());
245
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
246
+ state.nick = 'Alice';
247
+ await rt.persistConnectionState(state);
248
+ const sendSpy = vi.spyOn(fx.client, 'send');
249
+ await rt.getConnectionInfo('c1');
250
+ const conGets = sendSpy.mock.calls
251
+ .map((call) => call[0] as unknown)
252
+ .filter(
253
+ (cmd): cmd is GetCommand =>
254
+ cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
255
+ );
256
+ expect(conGets.length).toBeGreaterThan(0);
257
+ expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
258
+ sendSpy.mockRestore();
259
+ });
238
260
  });
239
261
 
240
262
  // -------------------------------------------------------------------------
@@ -51,6 +51,14 @@ describe('buildLambdaConfigInput', () => {
51
51
  expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
52
52
  });
53
53
 
54
+ it('threads SERVER_PASSWORD through to serverPassword', () => {
55
+ expect(buildLambdaConfigInput({ SERVER_PASSWORD: 'hunter2' }).serverPassword).toBe('hunter2');
56
+ });
57
+
58
+ it('omits serverPassword when SERVER_PASSWORD is undefined', () => {
59
+ expect('serverPassword' in buildLambdaConfigInput({})).toBe(false);
60
+ });
61
+
54
62
  it('omits operCreds when neither OPER_* is set', () => {
55
63
  expect('operCreds' in buildLambdaConfigInput({})).toBe(false);
56
64
  });