serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -22,9 +22,12 @@ import {
22
22
  type MtlsIdentityProvider,
23
23
  type NickHistoryStore,
24
24
  type ParsedServerConfig,
25
+ type ServerConfig,
25
26
  SystemClock,
26
27
  UuidIdFactory,
28
+ type WsFrameMode,
27
29
  createConnection,
30
+ frameToLines,
28
31
  } from '@serverless-ircd/irc-core';
29
32
  import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
30
33
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
@@ -33,8 +36,18 @@ import {
33
36
  type MarshalledConnection,
34
37
  unmarshalConnection,
35
38
  } from '../serialize.js';
39
+ import { AwsStats } from '../stats.js';
36
40
  import type { TablesConfig } from '../tables.js';
37
41
 
42
+ /**
43
+ * Wall-clock timestamp captured at this Lambda isolate's first module load
44
+ * (cold start). Used as the `uptimeStartedAt` anchor for {@link AwsStats}
45
+ * so `STATS u` reports uptime for the current isolate. A deployment-wide
46
+ * uptime would require a persistent record (future work); for now each
47
+ * isolate reports its own.
48
+ */
49
+ const LAMBDA_STARTUP_AT = Date.now();
50
+
38
51
  /** Parameters accepted by {@link handleDefault}. */
39
52
  export interface DefaultParams {
40
53
  dynamo: DynamoDBDocumentClient;
@@ -86,6 +99,12 @@ export interface DefaultParams {
86
99
  ids?: IdFactory;
87
100
  /** Optional management endpoint URL used when constructing a default client. */
88
101
  managementEndpoint?: string;
102
+ /**
103
+ * Config-reload source for `REHASH`. When omitted the runtime's
104
+ * `reloadConfig` rejects so the actor emits a graceful error-suffixed
105
+ * `382` and retains the prior config.
106
+ */
107
+ configLoader?: () => Promise<ServerConfig>;
89
108
  }
90
109
 
91
110
  /**
@@ -112,6 +131,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
112
131
  const persisted = result.Item as unknown as MarshalledConnection;
113
132
  const state = unmarshalConnection(persisted);
114
133
 
134
+ // Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
135
+ // Absent = legacy (no subprotocol negotiated). Drives both the inbound
136
+ // line-framing transport (spec: one IRC message per frame, never split)
137
+ // and the outbound delivery shape (spec: one postToConnection per line).
138
+ const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
139
+
115
140
  // Mark this frame's activity.
116
141
  state.lastSeen = clock.now();
117
142
 
@@ -141,6 +166,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
141
166
  handlers,
142
167
  managementApi: params.managementApi,
143
168
  clock,
169
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
144
170
  });
145
171
 
146
172
  const channelAccess = new LambdaChannelAccess(runtime, clock.now());
@@ -150,13 +176,27 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
150
176
  runtime,
151
177
  channels: channelAccess,
152
178
  serverConfig: params.serverConfig,
179
+ configSource: 'Secrets Manager',
153
180
  clock,
154
181
  ids,
155
182
  motd: params.motd,
183
+ transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
184
+ // AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
185
+ // `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
186
+ // reports per-isolate uptime; a deployment-wide uptime would require a
187
+ // persistent record (future work).
188
+ stats: new AwsStats({
189
+ dynamo: params.dynamo,
190
+ tables: params.tables,
191
+ uptimeStartedAt: LAMBDA_STARTUP_AT,
192
+ }),
156
193
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
157
194
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
158
195
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
159
196
  ...(params.history !== undefined ? { history: params.history } : {}),
197
+ // API Gateway terminates TLS before the Lambda is invoked, so every
198
+ // WebSocket connection is secure → user mode `S`.
199
+ secure: true,
160
200
  });
161
201
 
162
202
  try {
@@ -172,13 +212,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
172
212
  await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
173
213
 
174
214
  // In production, deliver outbound bytes back to the caller via APIGW.
215
+ // Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
216
+ // line per `postToConnection` (no trailing CRLF); legacy connections
217
+ // receive every line CRLF-joined in a single message.
175
218
  if (params.managementApi !== null && outbound.length > 0) {
176
- const data = outbound.join('\r\n');
177
219
  try {
178
- await params.managementApi.postToConnection({
179
- ConnectionId: params.connectionId,
180
- Data: data,
181
- });
220
+ await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
182
221
  } catch {
183
222
  // The handler still returns 200 — the actor's mutations are
184
223
  // persisted; the caller just didn't see the reply this frame.
@@ -187,6 +226,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
187
226
  return { statusCode: 200 };
188
227
  }
189
228
 
229
+ /**
230
+ * Delivers `lines` to `connectionId` via APIGW according to `mode`:
231
+ * - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
232
+ * - `spec-text` / `spec-binary` — one `postToConnection` per line, no
233
+ * trailing CRLF (the IRCv3 WebSocket contract: one IRC message per
234
+ * WebSocket message).
235
+ */
236
+ async function postOutbound(
237
+ api: ApiGatewayManagementApi | PostToConnection,
238
+ connectionId: string,
239
+ lines: string[],
240
+ mode: WsFrameMode,
241
+ ): Promise<void> {
242
+ if (mode === 'legacy') {
243
+ await api.postToConnection({ ConnectionId: connectionId, Data: lines.join('\r\n') });
244
+ return;
245
+ }
246
+ for (const line of lines) {
247
+ await api.postToConnection({ ConnectionId: connectionId, Data: line });
248
+ }
249
+ }
250
+
190
251
  /**
191
252
  * Writes the actor's mutated `state` back to the Connections row.
192
253
  *
@@ -21,6 +21,7 @@ import {
21
21
  type MotdProvider,
22
22
  type NickHistoryStore,
23
23
  type ParsedServerConfig,
24
+ type ServerConfig,
24
25
  StaticMotdProvider,
25
26
  SystemClock,
26
27
  } from '@serverless-ircd/irc-core';
@@ -68,6 +69,12 @@ export interface WebSocketEvent {
68
69
  */
69
70
  identity?: { sourceIp?: string };
70
71
  };
72
+ /**
73
+ * Request headers. APIGW populates `event.headers` on every route; the
74
+ * `$connect` upgrade carries the client's `Sec-WebSocket-Protocol` offer
75
+ * here so the handler can negotiate the IRCv3 subprotocol.
76
+ */
77
+ headers?: Record<string, string>;
71
78
  body?: string;
72
79
  }
73
80
 
@@ -75,6 +82,12 @@ export interface WebSocketEvent {
75
82
  export interface LambdaResponse {
76
83
  statusCode: number;
77
84
  body?: string;
85
+ /**
86
+ * Response headers. The `$connect` route sets `Sec-WebSocket-Protocol`
87
+ * to echo the agreed IRCv3 subprotocol back to the client; the CDK
88
+ * integration-response maps it onto the WebSocket handshake.
89
+ */
90
+ headers?: Record<string, string>;
78
91
  }
79
92
 
80
93
  /** Injectable dependencies. Tests construct one of these to bypass env loading. */
@@ -105,13 +118,24 @@ export interface HandlerDeps {
105
118
  * variant is a documented follow-up.
106
119
  */
107
120
  history?: NickHistoryStore;
121
+ /**
122
+ * Config-reload source for `REHASH` — re-invokes the Lambda env config
123
+ * loader (Secrets Manager / SSM / env vars). Bound once per cold start;
124
+ * each `REHASH` re-reads the live env so rotated oper creds / MOTD take
125
+ * effect without a redeploy. When omitted the actor's graceful failure
126
+ * path applies (error-suffixed `382`).
127
+ */
128
+ configLoader?: () => Promise<ServerConfig>;
108
129
  }
109
130
 
110
131
  /**
111
132
  * Strongly-typed event discriminated by routeKey. Useful for tests that
112
133
  * construct events directly.
113
134
  */
114
- export type ConnectEvent = { requestContext: { routeKey: '$connect'; connectionId: string } };
135
+ export type ConnectEvent = {
136
+ requestContext: { routeKey: '$connect'; connectionId: string };
137
+ headers?: Record<string, string>;
138
+ };
115
139
  export type DisconnectEvent = {
116
140
  requestContext: { routeKey: '$disconnect'; connectionId: string };
117
141
  };
@@ -162,8 +186,17 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
162
186
  tables: deps.tables,
163
187
  connectionId: connId,
164
188
  serverConfig: deps.serverConfig,
189
+ secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
165
190
  });
166
- if (outcome.admitted) return { statusCode: 200 };
191
+ if (outcome.admitted) {
192
+ if (outcome.subprotocol !== null) {
193
+ return {
194
+ statusCode: 200,
195
+ headers: { 'Sec-WebSocket-Protocol': outcome.subprotocol },
196
+ };
197
+ }
198
+ return { statusCode: 200 };
199
+ }
167
200
  return { statusCode: outcome.statusCode, body: outcome.reason };
168
201
  }
169
202
 
@@ -192,6 +225,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
192
225
  messages: deps.messages,
193
226
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
194
227
  ...(deps.history !== undefined ? { history: deps.history } : {}),
228
+ ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
195
229
  managementApi: deps.managementApi,
196
230
  });
197
231
  return result;
@@ -333,6 +367,10 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
333
367
  const messages = bindMessageStore(cfg);
334
368
  const history = new InMemoryNickHistoryStore(SystemClock);
335
369
  const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
370
+ // REHASH reload source: re-read the live env so a rotated oper password
371
+ // or MOTD takes effect on the next REHASH without a redeploy.
372
+ const configLoader = async (): Promise<ServerConfig> =>
373
+ loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
336
374
  return {
337
375
  dynamo,
338
376
  tables,
@@ -341,6 +379,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
341
379
  managementApi,
342
380
  messages,
343
381
  history,
382
+ configLoader,
344
383
  ...(accounts !== undefined ? { accounts } : {}),
345
384
  };
346
385
  }
@@ -53,6 +53,7 @@ import {
53
53
  type MotdProvider,
54
54
  type NickHistoryStore,
55
55
  type ParsedServerConfig,
56
+ type ServerConfig,
56
57
  SystemClock,
57
58
  UuidIdFactory,
58
59
  } from '@serverless-ircd/irc-core';
@@ -151,6 +152,19 @@ export interface NlbStreamParams {
151
152
  clock?: Clock;
152
153
  /** Injected for tests; defaults to {@link UuidIdFactory}. */
153
154
  ids?: IdFactory;
155
+ /**
156
+ * Config-reload source for `REHASH` (same as the wss `$default` path).
157
+ * When omitted the runtime's `reloadConfig` rejects so the actor emits a
158
+ * graceful error-suffixed `382`.
159
+ */
160
+ configLoader?: () => Promise<ServerConfig>;
161
+ /**
162
+ * Whether the NLB listener terminates TLS for this flow. Set to `true`
163
+ * when the target group fronts a TLS listener (the `irc+tls://` port) so
164
+ * the connection surfaces user mode `S`; omit / set `false` for a plain
165
+ * TCP listener. The stack code sets this from the listener protocol.
166
+ */
167
+ secure?: boolean;
154
168
  }
155
169
 
156
170
  /**
@@ -225,6 +239,7 @@ export async function handleNlbStream(
225
239
  handlers,
226
240
  managementApi: params.managementApi,
227
241
  clock,
242
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
228
243
  });
229
244
 
230
245
  const channelAccess = new NlbChannelAccess(runtime, now);
@@ -234,6 +249,7 @@ export async function handleNlbStream(
234
249
  runtime,
235
250
  channels: channelAccess,
236
251
  serverConfig: params.serverConfig,
252
+ configSource: 'Secrets Manager',
237
253
  clock,
238
254
  ids,
239
255
  motd: params.motd,
@@ -244,6 +260,8 @@ export async function handleNlbStream(
244
260
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
245
261
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
246
262
  ...(params.history !== undefined ? { history: params.history } : {}),
263
+ // Surface TLS fact when the NLB listener terminates TLS (irc+tls port).
264
+ ...(params.secure === true ? { secure: true } : {}),
247
265
  });
248
266
 
249
267
  if (lines.length > 0) {
@@ -23,6 +23,8 @@ export {
23
23
  loadDynamoAccountStore,
24
24
  } from './dynamo-account-store.js';
25
25
  export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
26
+ export { AwsStats } from './stats.js';
27
+ export type { AwsStatsOptions } from './stats.js';
26
28
  export {
27
29
  loadServerConfigFromLambdaEnv,
28
30
  type LambdaConfigEnv,
@@ -30,6 +30,7 @@ import type {
30
30
  RegistrationState,
31
31
  RosterEntry,
32
32
  UserModes,
33
+ WsFrameMode,
33
34
  } from '@serverless-ircd/irc-core';
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -55,6 +56,13 @@ export interface MarshalledConnection {
55
56
  connectionId: ConnId;
56
57
  registration: RegistrationState;
57
58
  capNegotiating: boolean;
59
+ /**
60
+ * Transport TLS fact (user mode `S`). Optional on the persisted shape so
61
+ * rows written before the field shipped deserialize with a `false`
62
+ * default via the coalescing in {@link unmarshalConnection}. Always
63
+ * written by {@link marshalConnection}.
64
+ */
65
+ secure?: boolean;
58
66
  caps: string[];
59
67
  /**
60
68
  * Present (as a JS `Set`, auto-marshalled to `SS`) only when the
@@ -76,6 +84,15 @@ export interface MarshalledConnection {
76
84
  away?: string;
77
85
  saslMech?: string;
78
86
  saslBuffer?: string;
87
+ /**
88
+ * Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
89
+ * Absent (`undefined`) on legacy connections (no subprotocol agreed) so
90
+ * the DynamoDB column is null by default — mirroring the CF adapter's
91
+ * hibernation-tag default. Set once and never mutated: `$default` uses
92
+ * `UpdateCommand` for its partial writes, so this field survives every
93
+ * subsequent frame.
94
+ */
95
+ wsMode?: WsFrameMode;
79
96
  }
80
97
 
81
98
  /**
@@ -92,6 +109,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
92
109
  connectionId: state.id,
93
110
  registration: state.registration,
94
111
  capNegotiating: state.capNegotiating,
112
+ secure: state.secure,
95
113
  caps: [...state.caps],
96
114
  userModes: { ...state.userModes },
97
115
  lastSeen: state.lastSeen,
@@ -118,7 +136,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
118
136
  /**
119
137
  * Materialises a {@link ConnectionState} from a stored row. Rehydrates
120
138
  * `caps` and `joinedChannels` into Sets. Throws on unrecognised
121
- * `version` values (forward migrations live in a future ticket).
139
+ * `version` values (forward migrations are not yet implemented).
122
140
  */
123
141
  export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
124
142
  if (row.version > CONNECTION_VERSION) {
@@ -130,13 +148,20 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
130
148
  id: row.connectionId,
131
149
  registration: row.registration,
132
150
  capNegotiating: row.capNegotiating,
151
+ // `secure` is absent on rows persisted before user mode `S` shipped;
152
+ // default to false (plain transport) so the state shape stays satisfied.
153
+ secure: row.secure ?? false,
133
154
  caps: new Set<string>(row.caps),
134
155
  // `joinedChannels` is absent on rows that never joined a channel
135
156
  // (DynamoDB rejects empty sets); default to an empty Set. The SDK
136
157
  // unmarshalls `SS` back to a native Set, which the Set constructor
137
158
  // copies; arrays (older rows) also iterate fine.
138
159
  joinedChannels: new Set<ChanName>(row.joinedChannels ?? []),
139
- userModes: { ...row.userModes },
160
+ // `userModes.tls` is absent on rows persisted before user mode `S`
161
+ // shipped; default to false so the field is a real boolean rather than
162
+ // `undefined`. Cast to Partial so TypeScript accepts the explicit
163
+ // default without flagging the spread overwrite (TS2783).
164
+ userModes: normalizeUserModes(row.userModes),
140
165
  lastSeen: row.lastSeen,
141
166
  connectedSince: row.connectedSince,
142
167
  };
@@ -152,6 +177,19 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
152
177
  return state;
153
178
  }
154
179
 
180
+ /**
181
+ * Defaults `tls` to false on a deserialized `UserModes`. Rows persisted
182
+ * before user mode `S` shipped lack the field; the DynamoDB SDK round-trip
183
+ * yields `undefined`, which would leak into runtime code expecting a real
184
+ * boolean. Casting to `Partial` lets the explicit default apply without
185
+ * TypeScript flagging the spread overwrite (TS2783).
186
+ */
187
+ function normalizeUserModes(um: UserModes): UserModes {
188
+ const out: Partial<UserModes> = { ...um };
189
+ if (out.tls === undefined) out.tls = false;
190
+ return out as UserModes;
191
+ }
192
+
155
193
  // ---------------------------------------------------------------------------
156
194
  // Nicks
157
195
  // ---------------------------------------------------------------------------
@@ -0,0 +1,80 @@
1
+ /**
2
+ * AWS-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * directly from DynamoDB: a single `Scan` over `Connections` (the source
6
+ * of truth for every live connection) and one over `ChannelMeta` (one row
7
+ * per channel). Classification (oper / invisible / unknown) is delegated
8
+ * to the shared {@link computeStatsSnapshot} helper in irc-core so the
9
+ * classification rules live in exactly one place.
10
+ *
11
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
12
+ * {@link AwsRuntime}; cheap to build, no caching. `uptimeStartedAt` is
13
+ * supplied by the caller (typically the Lambda's cold-start timestamp,
14
+ * stashed in module scope at first import).
15
+ */
16
+
17
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
18
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
19
+ import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
20
+ import type { MarshalledConnection } from './serialize.js';
21
+ import { unmarshalConnection } from './serialize.js';
22
+ import type { TablesConfig } from './tables.js';
23
+
24
+ export interface AwsStatsOptions {
25
+ /** DynamoDB DocumentClient (the same client used by {@link AwsRuntime}). */
26
+ readonly dynamo: DynamoDBDocumentClient;
27
+ /** Logical→physical table-name map. */
28
+ readonly tables: TablesConfig;
29
+ /** Epoch-ms the deployment counts uptime from (Lambda cold start). */
30
+ readonly uptimeStartedAt: number;
31
+ /**
32
+ * Optional high-water marks for `265 RPL_LOCALUSERS` / `266
33
+ * RPL_GLOBALUSERS`. When omitted the live count is reported as the max
34
+ * (so the wire numeric never claims a max below the current count).
35
+ */
36
+ readonly maxLocalConns?: number;
37
+ readonly maxGlobalConns?: number;
38
+ }
39
+
40
+ export class AwsStats {
41
+ private readonly dynamo: DynamoDBDocumentClient;
42
+ private readonly tables: TablesConfig;
43
+ private readonly uptimeStartedAt: number;
44
+ private readonly maxLocalConns: number | undefined;
45
+ private readonly maxGlobalConns: number | undefined;
46
+
47
+ constructor(opts: AwsStatsOptions) {
48
+ this.dynamo = opts.dynamo;
49
+ this.tables = opts.tables;
50
+ this.uptimeStartedAt = opts.uptimeStartedAt;
51
+ this.maxLocalConns = opts.maxLocalConns;
52
+ this.maxGlobalConns = opts.maxGlobalConns;
53
+ }
54
+
55
+ async getStats(): Promise<ServerStatsSnapshot> {
56
+ // Scan Connections in a single request. DynamoDB Local + production
57
+ // both paginate at 1MB; for the serverless-IRC scale (≤ tens of
58
+ // thousands of connections per deployment) a single page covers the
59
+ // realistic ceiling. A deployment that outgrows this should maintain
60
+ // rolling count records (updated on connect/disconnect) rather than
61
+ // Scan — a tracked follow-up.
62
+ const connResult = await this.dynamo.send(
63
+ new ScanCommand({ TableName: this.tables.Connections }),
64
+ );
65
+ const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
66
+ const connections = connItems.map(unmarshalConnection);
67
+
68
+ // Channel count: one row per channel in `ChannelMeta`, again in a
69
+ // single page (channel counts are small).
70
+ const chanResult = await this.dynamo.send(
71
+ new ScanCommand({ TableName: this.tables.ChannelMeta, Select: 'COUNT' }),
72
+ );
73
+ const channelCount = chanResult.Count ?? 0;
74
+
75
+ return computeStatsSnapshot(connections, channelCount, this.uptimeStartedAt, {
76
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
77
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
78
+ });
79
+ }
80
+ }
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
- import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
14
15
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
16
  import {
16
17
  loadDynamoAccountStore,
@@ -126,6 +127,48 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
126
127
  false,
127
128
  );
128
129
  });
130
+
131
+ it('silently skips rows with missing or wrong-typed credential fields', async () => {
132
+ // The scan path must tolerate partially-malformed rows (legacy seeds,
133
+ // schema drift) without throwing — it just skips them. Each branch of
134
+ // the per-field `typeof === 'string'` guard is exercised by writing a
135
+ // row where exactly one non-key field has the wrong type. (The
136
+ // `account` field is the partition key, so DynamoDB itself enforces
137
+ // its type at write time and the false branch is not exercisable
138
+ // through the SDK; that branch is a defensive guard against
139
+ // synthetically-shaped Scan results.)
140
+ const client = requireFx().client;
141
+ const table = requireFx().tableName;
142
+ // Well-formed row — must survive.
143
+ await putAccountCredential(client, table, 'good', 's3cret');
144
+ // Row with non-string `algorithm`.
145
+ await client.send(
146
+ new PutCommand({
147
+ TableName: table,
148
+ Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
149
+ }),
150
+ );
151
+ // Row with non-string `salt`.
152
+ await client.send(
153
+ new PutCommand({
154
+ TableName: table,
155
+ Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
156
+ }),
157
+ );
158
+ // Row with non-string `hash`.
159
+ await client.send(
160
+ new PutCommand({
161
+ TableName: table,
162
+ Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
163
+ }),
164
+ );
165
+ const store = await loadDynamoAccountStore(client, table);
166
+ expect(store).toBeDefined();
167
+ expect(store?.size).toBe(1);
168
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
169
+ true,
170
+ );
171
+ });
129
172
  });
130
173
 
131
174
  describe.skipIf(!available)('resolveAccountStore precedence', () => {
@@ -139,10 +182,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
139
182
 
140
183
  it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
141
184
  await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
142
- const cfgWithSasl = {
143
- ...DEFAULT_SERVER_CONFIG,
185
+ const cfgWithSasl = makeTestServerConfig({
144
186
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
145
- };
187
+ });
146
188
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
147
189
  expect(store).toBeDefined();
148
190
  // Table account verifies; config account does NOT (table is authoritative).
@@ -155,10 +197,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
155
197
  });
156
198
 
157
199
  it('falls back to the config-seeded store when the table is empty', async () => {
158
- const cfgWithSasl = {
159
- ...DEFAULT_SERVER_CONFIG,
200
+ const cfgWithSasl = makeTestServerConfig({
160
201
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
161
- };
202
+ });
162
203
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
163
204
  expect(store).toBeDefined();
164
205
  expect(
@@ -170,7 +211,7 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
170
211
  const store = await resolveAccountStore(
171
212
  requireFx().client,
172
213
  requireFx().tableName,
173
- DEFAULT_SERVER_CONFIG,
214
+ makeTestServerConfig(),
174
215
  );
175
216
  expect(store).toBeUndefined();
176
217
  });
@@ -1,9 +1,6 @@
1
1
  import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
2
- import {
3
- DEFAULT_SERVER_CONFIG,
4
- type ParsedServerConfig,
5
- type SaslPayload,
6
- } from '@serverless-ircd/irc-core';
2
+ import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
3
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
7
4
  import { mockClient } from 'aws-sdk-client-mock';
8
5
  import { describe, expect, it } from 'vitest';
9
6
  import {
@@ -21,7 +18,7 @@ function plainPayload(username: string, password: string): SaslPayload {
21
18
 
22
19
  /** Builds a minimal parsed config with the supplied saslAccounts. */
23
20
  function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
24
- return { ...DEFAULT_SERVER_CONFIG, saslAccounts: accounts };
21
+ return makeTestServerConfig({ saslAccounts: accounts });
25
22
  }
26
23
 
27
24
  // ---------------------------------------------------------------------------
@@ -184,7 +181,7 @@ describe('resolveAccountStore', () => {
184
181
  'Accounts',
185
182
  fallback === undefined
186
183
  ? undefined
187
- : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
184
+ : makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
188
185
  );
189
186
  expect(store).toBeDefined();
190
187
  expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
@@ -53,8 +53,11 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
53
53
 
54
54
  /**
55
55
  * Factory entry — appends to a suite's FACTORIES array to opt the scenarios
56
- * into the AWS matrix. Returns a no-op factory when DynamoDB Local is
57
- * unavailable so `describe.skipIf` short-circuits before any test runs.
56
+ * into the AWS matrix. The whole describe block is gated by
57
+ * `describe.skipIf(process.env.DDB_AVAILABLE !== '1')` at the call site
58
+ * (see `aws-integration.test.ts`), so no scenarios run when DynamoDB
59
+ * Local is unavailable. This factory itself assumes DDB is up; its
60
+ * `create()` throws if `DYNAMO_ENDPOINT` was never set.
58
61
  */
59
62
  export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
60
63
  name: 'aws+ws',
@@ -6,12 +6,16 @@
6
6
  * a fresh {@link AwsHarness} which mints uniquely-prefixed DynamoDB
7
7
  * tables against the global-setup's endpoint.
8
8
  *
9
- * Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
10
- * DynamoDB Local cannot be booted (no Docker, no Java). When available,
11
- * every scenario MUST pass — that is the 040 acceptance criterion.
9
+ * Gated on `process.env.DDB_AVAILABLE`: the whole suite is skipped via
10
+ * `describe.skipIf` when DynamoDB Local cannot be booted (no Docker, no
11
+ * Java). When available, every scenario MUST pass — that is the
12
+ * acceptance criterion.
12
13
  */
13
14
 
14
15
  import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
16
+ import { describe } from 'vitest';
15
17
  import { awsHarnessFactory } from './aws-harness.js';
16
18
 
17
- runIrcScenarios([awsHarnessFactory]);
19
+ describe.skipIf(process.env.DDB_AVAILABLE !== '1')('aws integration scenarios', () => {
20
+ runIrcScenarios([awsHarnessFactory]);
21
+ });