serverless-ircd 0.2.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 (221) 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 +258 -18
  8. package/README.md +72 -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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -18,7 +18,11 @@
18
18
  * so a future `TransactWriteItems` can span Connections ↔ membership
19
19
  * atomically. (Atomicity across Connections/ChannelMembers lands in the
20
20
  * membership-transaction follow-up.)
21
- * • `Accounts` never stores plaintext credentials (hash only).
21
+ * • `Accounts` stores SASL PLAIN credentials as scrypt
22
+ * `HashedAccountCredential` rows (`{ account, algorithm, salt,
23
+ * hash }`, base64) — never plaintext. Loaded at Lambda cold start
24
+ * by `loadDynamoAccountStore` and seeded via
25
+ * `putAccountCredential`.
22
26
  * • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
23
27
  * are reaped automatically in addition to the explicit sweeper.
24
28
  */
@@ -19,6 +19,17 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
19
19
  export interface LambdaConfigEnv {
20
20
  SERVER_NAME?: string;
21
21
  NETWORK_NAME?: string;
22
+ /**
23
+ * Server version surfaced in `002`/`004`/`351`/`371`. When unset the
24
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
25
+ */
26
+ SERVER_VERSION?: string;
27
+ /**
28
+ * "Created" text for `003 RPL_CREATED`. A numeric string is parsed into
29
+ * an epoch-ms number so the reducer formats it as a UTC timestamp; any
30
+ * other value is passed through verbatim.
31
+ */
32
+ CREATED_AT?: string;
22
33
  MOTD?: string;
23
34
  MAX_CLIENTS?: string;
24
35
  CHANNEL_PREFIXES?: string;
@@ -31,6 +42,13 @@ export interface LambdaConfigEnv {
31
42
  TOPIC_LEN?: string;
32
43
  MAX_LIST_ENTRIES?: string;
33
44
  QUIT_MESSAGE?: string;
45
+ /**
46
+ * SASL PLAIN accounts. Newline-delimited `username:password` pairs
47
+ * (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
48
+ * the `saslAccounts` config field so {@link bindAccountStore} can seed
49
+ * an `InMemoryAccountStore` at boot.
50
+ */
51
+ SASL_ACCOUNTS?: string;
34
52
  }
35
53
 
36
54
  /**
@@ -46,6 +64,12 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
46
64
  serverName: env.SERVER_NAME,
47
65
  networkName: env.NETWORK_NAME,
48
66
  };
67
+ if (env.SERVER_VERSION !== undefined) {
68
+ input.serverVersion = env.SERVER_VERSION;
69
+ }
70
+ if (env.CREATED_AT !== undefined) {
71
+ input.createdAt = parseCreatedAt(env.CREATED_AT);
72
+ }
49
73
  if (env.MOTD !== undefined && env.MOTD.length > 0) {
50
74
  input.motdLines = env.MOTD.split('\n');
51
75
  }
@@ -84,9 +108,47 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
84
108
  if (env.QUIT_MESSAGE !== undefined) {
85
109
  input.quitMessage = env.QUIT_MESSAGE;
86
110
  }
111
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
112
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
113
+ }
87
114
  return input;
88
115
  }
89
116
 
117
+ /**
118
+ * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
119
+ *
120
+ * A purely numeric string is treated as epoch-ms (returned as a number so
121
+ * the reducer formats it as a UTC timestamp); any other value is returned
122
+ * verbatim as a human-readable string.
123
+ */
124
+ function parseCreatedAt(raw: string): string | number {
125
+ if (/^\d+$/.test(raw.trim())) {
126
+ const n = Number(raw.trim());
127
+ if (Number.isFinite(n)) return n;
128
+ }
129
+ return raw;
130
+ }
131
+
132
+ /**
133
+ * Parses a newline-delimited `username:password` string into the config's
134
+ * `saslAccounts` shape. Malformed entries (missing colon, empty fields)
135
+ * are skipped so a trailing newline or partial edit does not break boot.
136
+ */
137
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
138
+ const out: Array<{ username: string; password: string }> = [];
139
+ for (const line of raw.split('\n')) {
140
+ const trimmed = line.trim();
141
+ if (trimmed.length === 0) continue;
142
+ const sep = trimmed.indexOf(':');
143
+ if (sep <= 0) continue;
144
+ const username = trimmed.slice(0, sep);
145
+ const password = trimmed.slice(sep + 1);
146
+ if (username.length === 0 || password.length === 0) continue;
147
+ out.push({ username, password });
148
+ }
149
+ return out;
150
+ }
151
+
90
152
  /**
91
153
  * Loads and validates the server config from a Lambda env. Throws a
92
154
  * readable `Error` listing every invalid field on failure.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * DynamoDB-backed {@link AccountStore} primitives for the AWS adapter.
3
+ *
4
+ * The `Accounts` DynamoDB table stores SASL PLAIN credentials as
5
+ * **scrypt hashes** (never plaintext). Each row is a flat
6
+ * {@link HashedAccountCredential} (`{ account, algorithm, salt, hash }`).
7
+ *
8
+ * Two-phase construction keeps the `AccountStore` port synchronous:
9
+ * 1. {@link loadDynamoAccountStore} (async) scans the table at Lambda
10
+ * cold start and returns a fully-populated `DynamoAccountStore`.
11
+ * 2. `DynamoAccountStore(entries)` (sync constructor) takes the
12
+ * pre-loaded entries. The class itself has zero DynamoDB
13
+ * coupling — it works equally well with entries from any source.
14
+ *
15
+ * This mirrors the pre-load pattern documented in
16
+ * `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
17
+ * adapters that need an async backend pre-load the data at boot into a
18
+ * synchronously-readable store.
19
+ *
20
+ * The scrypt hashing helpers (`hashAccountCredential` /
21
+ * `verifyHashedPassword` + the `HashedAccountCredential` type) live in
22
+ * `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
23
+ * are re-exported below so existing call sites importing from this module
24
+ * keep working.
25
+ */
26
+
27
+ import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
28
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
29
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
30
+ import {
31
+ type HashedAccountCredential,
32
+ HashedAccountStore,
33
+ hashAccountCredential,
34
+ verifyHashedPassword,
35
+ } from '@serverless-ircd/irc-core';
36
+
37
+ // Re-export so callers importing these from `@serverless-ircd/aws-adapter`
38
+ // (the historical home of the hashing helpers) keep compiling.
39
+ export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
40
+
41
+ /**
42
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
43
+ *
44
+ * Now a re-export of the shared `HashedAccountStore` from `irc-core` —
45
+ * both adapters use one store implementation. The AWS-specific surface
46
+ * (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
47
+ * scan boundary.
48
+ */
49
+ export { HashedAccountStore as DynamoAccountStore };
50
+
51
+ /**
52
+ * Scans the `Accounts` table and returns a fully-populated
53
+ * {@link DynamoAccountStore}, or `undefined` when the table has no rows.
54
+ *
55
+ * Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
56
+ * scan page are fully loaded. Rows missing required attributes are
57
+ * silently skipped.
58
+ */
59
+ export async function loadDynamoAccountStore(
60
+ docClient: DynamoDBDocumentClient,
61
+ tableName: string,
62
+ ): Promise<HashedAccountStore | undefined> {
63
+ const entries: HashedAccountCredential[] = [];
64
+ let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
65
+ do {
66
+ const result: ScanCommandOutput = await docClient.send(
67
+ new ScanCommand({
68
+ TableName: tableName,
69
+ ExclusiveStartKey: exclusiveStartKey,
70
+ }),
71
+ );
72
+ for (const item of result.Items ?? []) {
73
+ const account = typeof item.account === 'string' ? item.account : undefined;
74
+ const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
75
+ const salt = typeof item.salt === 'string' ? item.salt : undefined;
76
+ const hash = typeof item.hash === 'string' ? item.hash : undefined;
77
+ if (
78
+ account !== undefined &&
79
+ algorithm !== undefined &&
80
+ salt !== undefined &&
81
+ hash !== undefined
82
+ ) {
83
+ entries.push({
84
+ account,
85
+ algorithm: algorithm as HashedAccountCredential['algorithm'],
86
+ salt,
87
+ hash,
88
+ });
89
+ }
90
+ }
91
+ exclusiveStartKey = result.LastEvaluatedKey;
92
+ } while (exclusiveStartKey !== undefined);
93
+ if (entries.length === 0) return undefined;
94
+ return new HashedAccountStore(entries);
95
+ }
@@ -4,11 +4,18 @@
4
4
  * Persists a fresh `Connections` row keyed by the APIGW connection id.
5
5
  * Subsequent `$default` invocations load this row, run the actor, and
6
6
  * persist the mutated state back.
7
+ *
8
+ * Admission gate: before writing the row, the handler counts the live
9
+ * `Connections` rows and rejects the upgrade with `429 Too Many Requests`
10
+ * once `serverConfig.maxClients` is reached. A `429` (not `403`) is used
11
+ * because the rejection is a capacity / back-pressure signal — the client
12
+ * is welcome to retry — rather than an authorisation failure.
7
13
  */
8
14
 
9
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
10
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
16
+ import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
17
  import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
18
+ import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
12
19
  import { marshalConnection } from '../serialize.js';
13
20
  import type { TablesConfig } from '../tables.js';
14
21
  import { createInitialConnectionState } from './state.js';
@@ -20,20 +27,43 @@ export interface ConnectParams {
20
27
  connectionId: string;
21
28
  /** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
22
29
  now?: number;
23
- /** Reserved for future use; required so the signature mirrors `$default`. */
24
- serverConfig?: ParsedServerConfig;
30
+ /** Server config consulted for the `maxClients` admission cap. */
31
+ serverConfig: ParsedServerConfig;
25
32
  }
26
33
 
34
+ /** Re-exported so the dispatcher imports the outcome type from one place. */
35
+ export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
36
+
27
37
  /**
28
- * Inserts the Connections row for a fresh WebSocket upgrade.
38
+ * Enforces the `maxClients` admission cap, then inserts the Connections
39
+ * row for a fresh WebSocket upgrade.
40
+ *
41
+ * Idempotent on success: APIGW retries `$connect` on transient failures,
42
+ * so the write uses `PutCommand` (overwrites) rather than a conditional
43
+ * insert.
44
+ *
45
+ * TOCTOU note: DynamoDB cannot conditionally write based on a row count,
46
+ * so the count-then-put sequence has a race window. This is an accepted
47
+ * trade-off for an admission cap — it bounds growth under steady load
48
+ * rather than enforcing a hard invariant. Two concurrent upgrades that
49
+ * both observe `count == cap - 1` will both be admitted.
29
50
  *
30
- * Idempotent: APIGW retries `$connect` on transient failures, so we
31
- * use `PutCommand` (overwrites) rather than a conditional insert.
51
+ * Per-IP admission (`maxConnectionsPerIp`) is intentionally NOT enforced
52
+ * here. irc-core's in-memory `AdmissionStats` is unsuitable for Lambda
53
+ * (each invocation is a fresh process with no shared counters), and a
54
+ * filtered full-table `Scan` per `$connect` would double the DynamoDB
55
+ * cost on every connect (and `maxConnectionsPerIp` is on by default).
56
+ * The correct fix is to persist `sourceIp` on the Connections row and
57
+ * add a DynamoDB GSI keyed by `sourceIp`, then feed the GSI's count
58
+ * into {@link decideConnectAdmission}'s `perIp` branch. That schema
59
+ * change is tracked as a follow-up; the pure policy already supports it.
32
60
  */
33
- export async function handleConnect(params: ConnectParams): Promise<void> {
61
+ export async function handleConnect(params: ConnectParams): Promise<AdmissionOutcome> {
34
62
  const now = params.now ?? Date.now();
63
+ const total = await countConnections(params.dynamo, params.tables.Connections);
64
+ const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
65
+ if (!outcome.admitted) return outcome;
35
66
  const state = createInitialConnectionState(params.connectionId, now);
36
- void params.serverConfig; // reserved for max-clients gating (later ticket)
37
67
  const row = marshalConnection(state, now);
38
68
  await params.dynamo.send(
39
69
  new PutCommand({
@@ -41,4 +71,30 @@ export async function handleConnect(params: ConnectParams): Promise<void> {
41
71
  Item: row,
42
72
  }),
43
73
  );
74
+ return outcome;
75
+ }
76
+
77
+ /**
78
+ * Paginated `Scan` with `Select: COUNT` over the `Connections` table.
79
+ * A single Scan page caps at 1MB, so the loop follows
80
+ * `LastEvaluatedKey` until every page has been summed.
81
+ */
82
+ async function countConnections(
83
+ dynamo: DynamoDBDocumentClient,
84
+ tableName: string,
85
+ ): Promise<number> {
86
+ let total = 0;
87
+ let lastKey: unknown = undefined;
88
+ do {
89
+ const res = await dynamo.send(
90
+ new ScanCommand({
91
+ TableName: tableName,
92
+ Select: 'COUNT',
93
+ ...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
94
+ }),
95
+ );
96
+ total += res.Count ?? 0;
97
+ lastKey = res.LastEvaluatedKey;
98
+ } while (lastKey !== undefined);
99
+ return total;
44
100
  }
@@ -12,12 +12,15 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
12
12
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
13
13
  import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
14
14
  import {
15
+ type AccountStore,
15
16
  type ChannelState,
16
17
  type Clock,
17
18
  type ConnectionState,
18
19
  type IdFactory,
19
20
  type MessageStore,
20
21
  type MotdProvider,
22
+ type MtlsIdentityProvider,
23
+ type NickHistoryStore,
21
24
  type ParsedServerConfig,
22
25
  SystemClock,
23
26
  UuidIdFactory,
@@ -46,6 +49,36 @@ export interface DefaultParams {
46
49
  * behaviour of callers that have not opted in.
47
50
  */
48
51
  messages?: MessageStore;
52
+ /**
53
+ * SASL account verification source. When omitted the actor runs with
54
+ * `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
55
+ * — preserves the behaviour of callers that have not opted in.
56
+ */
57
+ accounts?: AccountStore;
58
+ /**
59
+ * mTLS client-cert identity source for SASL EXTERNAL. When omitted the
60
+ * actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
61
+ * fails with `904` — preserves the behaviour of callers that have not
62
+ * opted in.
63
+ *
64
+ * AWS APIGW custom-domain mTLS setup:
65
+ * 1. Upload a PEM trust store to S3 (the CA bundle clients must present).
66
+ * 2. Create a custom domain with `mutualTlsAuthentication` pointing at the
67
+ * S3 trust store URI.
68
+ * 3. Map the custom domain to the WebSocket API stage.
69
+ * 4. The `$connect` event's `requestContext.identity.clientCertSubjectDN`
70
+ * carries the verified cert subject — capture it at connect time and
71
+ * persist it alongside the connection row, then construct an
72
+ * {@link MtlsIdentityProvider} that resolves it by connection ID.
73
+ */
74
+ mtlsIdentity?: MtlsIdentityProvider;
75
+ /**
76
+ * Nick-history source for the `WHOWAS` command. When omitted the actor
77
+ * runs with `ctx.history` undefined so `WHOWAS` always returns
78
+ * `406`+`369` — preserves the behaviour of callers that have not opted
79
+ * in. A DynamoDB-backed persistent variant is a documented follow-up.
80
+ */
81
+ history?: NickHistoryStore;
49
82
  managementApi: ApiGatewayManagementApi | PostToConnection | null;
50
83
  /** Injected for tests; defaults to {@link SystemClock}. */
51
84
  clock?: Clock;
@@ -91,8 +124,13 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
91
124
  for (const l of lines) outbound.push(l.text);
92
125
  },
93
126
  disconnect: () => {
94
- // Lambda cannot close its own socket from inside the handler
95
- // (the `$disconnect` route fires when APIGW observes the close).
127
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
128
+ // socket. The canonical teardown is the `$disconnect` route (which
129
+ // fires when APIGW observes the close) plus the sweeper/ping-checker
130
+ // for connections that vanish without one. The actor's state
131
+ // mutations (nick release, roster deltas) are persisted below before
132
+ // this invocation returns, so the eventual `$disconnect` completes
133
+ // the fanout. See docs/AWS-Deployment.md §14.1.
96
134
  },
97
135
  snapshot: () => state,
98
136
  };
@@ -116,6 +154,9 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
116
154
  ids,
117
155
  motd: params.motd,
118
156
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
157
+ ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
158
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
159
+ ...(params.history !== undefined ? { history: params.history } : {}),
119
160
  });
120
161
 
121
162
  try {
@@ -2,13 +2,15 @@
2
2
  * `$disconnect` handler — Lambda entry for `event.requestContext.routeKey === '$disconnect'`.
3
3
  *
4
4
  * Tears down the connection via {@link cleanupConnection} — deletes the
5
- * Connections row, every ChannelMembers row owned by it, and the nick
6
- * reservation if any. Idempotent: every step tolerates a missing row.
5
+ * Connections row, every ChannelMembers row owned by it, the nick
6
+ * reservation if any, and broadcasts a `QUIT` line to every peer sharing
7
+ * a channel with the disconnecting connection. Idempotent: every step
8
+ * tolerates a missing row.
7
9
  */
8
10
 
9
11
  import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
10
12
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
11
- import { cleanupConnection } from '../aws-runtime.js';
13
+ import { type PostToConnection, cleanupConnection } from '../aws-runtime.js';
12
14
  import type { TablesConfig } from '../tables.js';
13
15
 
14
16
  /** Parameters accepted by {@link handleDisconnect}. */
@@ -16,14 +18,31 @@ export interface DisconnectParams {
16
18
  dynamo: DynamoDBDocumentClient;
17
19
  tables: TablesConfig;
18
20
  connectionId: string;
19
- /** Reserved for future "broadcast QUIT to peers" work; unused today. */
20
- managementApi?: ApiGatewayManagementApi | null;
21
+ /**
22
+ * Backend used to push the `QUIT` fanout to peers. `null` skips the
23
+ * fanout (the socket is already gone, but peers still need the QUIT
24
+ * line so their rosters prune the ghost member).
25
+ */
26
+ managementApi?: ApiGatewayManagementApi | PostToConnection | null;
27
+ /**
28
+ * QUIT reason advertised to peers on fanout. Defaults to
29
+ * `'Client Quit'` (the same default the QUIT reducer uses when a
30
+ * client sends `QUIT` with no reason).
31
+ */
32
+ quitMessage?: string;
21
33
  }
22
34
 
23
35
  /**
24
- * Cleans up the connection state. Safe to call after APIGW has already
25
- * torn the WebSocket down the management API is best-effort only.
36
+ * Cleans up the connection state and broadcasts QUIT to shared-channel
37
+ * peers. Safe to call after APIGW has already torn the WebSocket down —
38
+ * the management API is best-effort only.
26
39
  */
27
40
  export async function handleDisconnect(params: DisconnectParams): Promise<void> {
28
- await cleanupConnection(params.dynamo, params.tables, params.connectionId, null);
41
+ await cleanupConnection(
42
+ params.dynamo,
43
+ params.tables,
44
+ params.connectionId,
45
+ params.managementApi ?? null,
46
+ params.quitMessage,
47
+ );
29
48
  }
@@ -14,12 +14,17 @@
14
14
  import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
15
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import {
17
+ type AccountStore,
17
18
  EmptyMotdProvider,
19
+ InMemoryNickHistoryStore,
18
20
  type MessageStore,
19
21
  type MotdProvider,
22
+ type NickHistoryStore,
20
23
  type ParsedServerConfig,
21
24
  StaticMotdProvider,
25
+ SystemClock,
22
26
  } from '@serverless-ircd/irc-core';
27
+ import { resolveAccountStore } from '../account-store.js';
23
28
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
24
29
  import { createDynamoDocumentClient } from '../dynamo.js';
25
30
  import { bindMessageStore } from '../message-store.js';
@@ -27,6 +32,13 @@ import { TABLE_KEYS, type TablesConfig } from '../tables.js';
27
32
  import { handleConnect } from './connect.js';
28
33
  import { handleDefault } from './default.js';
29
34
  import { handleDisconnect } from './disconnect.js';
35
+ import {
36
+ type NlbStreamEvent,
37
+ type NlbStreamParams,
38
+ type NlbStreamResponse,
39
+ deriveNlbConnectionId,
40
+ handleNlbStream,
41
+ } from './nlb-stream.js';
30
42
  import {
31
43
  DEFAULT_PING_INTERVAL_MS,
32
44
  DEFAULT_PONG_TIMEOUT_MS,
@@ -48,6 +60,13 @@ export interface WebSocketEvent {
48
60
  connectionId: string;
49
61
  domainName?: string;
50
62
  stage?: string;
63
+ /**
64
+ * Source IP of the connecting client. APIGW populates
65
+ * `requestContext.identity.sourceIp` on every `$connect`. Carried on
66
+ * the event type so the (currently deferred) per-IP admission gate
67
+ * can consume it without reshaping the handler signature.
68
+ */
69
+ identity?: { sourceIp?: string };
51
70
  };
52
71
  body?: string;
53
72
  }
@@ -72,6 +91,20 @@ export interface HandlerDeps {
72
91
  * DynamoDB-backed variant is a documented follow-up.
73
92
  */
74
93
  messages: MessageStore;
94
+ /**
95
+ * SASL account verification source bound to every actor. Constructed
96
+ * once per cold start (memoised via {@link cachedDeps}) so it survives
97
+ * across warm Lambda invocations. `undefined` when no accounts are
98
+ * configured (the default) so `ctx.accounts` stays unset.
99
+ */
100
+ accounts?: AccountStore;
101
+ /**
102
+ * Nick-history source bound to every actor. Constructed once per cold
103
+ * start (memoised via {@link cachedDeps}) as an in-memory store so it
104
+ * survives across warm Lambda invocations. A DynamoDB-backed persistent
105
+ * variant is a documented follow-up.
106
+ */
107
+ history?: NickHistoryStore;
75
108
  }
76
109
 
77
110
  /**
@@ -90,6 +123,15 @@ export type DefaultEvent = {
90
123
  /** Memoised `HandlerDeps`; built on the first cold-start invocation. */
91
124
  let cachedDeps: HandlerDeps | undefined;
92
125
 
126
+ /**
127
+ * In-flight cold-start build. `buildDepsFromEnv` is async (it scans the
128
+ * `Accounts` DynamoDB table for SASL credentials), so under concurrent
129
+ * warm-up two events could race past the `cachedDeps` check and each
130
+ * trigger a full scan. Memoising the promise guarantees the scan runs
131
+ * exactly once per cold start.
132
+ */
133
+ let cachedDepsPromise: Promise<HandlerDeps> | undefined;
134
+
93
135
  /**
94
136
  * The Lambda entry point. Routes on `event.requestContext.routeKey`.
95
137
  *
@@ -98,7 +140,11 @@ let cachedDeps: HandlerDeps | undefined;
98
140
  */
99
141
  export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> => {
100
142
  if (cachedDeps === undefined) {
101
- cachedDeps = buildDepsFromEnv();
143
+ if (cachedDepsPromise === undefined) {
144
+ cachedDepsPromise = buildDepsFromEnv();
145
+ }
146
+ cachedDeps = await cachedDepsPromise;
147
+ cachedDepsPromise = undefined;
102
148
  }
103
149
  const deps = cachedDeps;
104
150
  return dispatch(event, deps);
@@ -110,14 +156,16 @@ export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> =>
110
156
  export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
111
157
  const connId = event.requestContext.connectionId;
112
158
  switch (event.requestContext.routeKey) {
113
- case '$connect':
114
- await handleConnect({
159
+ case '$connect': {
160
+ const outcome = await handleConnect({
115
161
  dynamo: deps.dynamo,
116
162
  tables: deps.tables,
117
163
  connectionId: connId,
118
164
  serverConfig: deps.serverConfig,
119
165
  });
120
- return { statusCode: 200 };
166
+ if (outcome.admitted) return { statusCode: 200 };
167
+ return { statusCode: outcome.statusCode, body: outcome.reason };
168
+ }
121
169
 
122
170
  case '$disconnect':
123
171
  await handleDisconnect({
@@ -125,6 +173,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
125
173
  tables: deps.tables,
126
174
  connectionId: connId,
127
175
  managementApi: deps.managementApi,
176
+ quitMessage: deps.serverConfig.quitMessage,
128
177
  });
129
178
  return { statusCode: 200 };
130
179
 
@@ -141,6 +190,8 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
141
190
  serverConfig: deps.serverConfig,
142
191
  motd: deps.motd,
143
192
  messages: deps.messages,
193
+ ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
194
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
144
195
  managementApi: deps.managementApi,
145
196
  });
146
197
  return result;
@@ -159,6 +210,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
159
210
  */
160
211
  export function __resetHandlerCacheForTests(): void {
161
212
  cachedDeps = undefined;
213
+ cachedDepsPromise = undefined;
162
214
  }
163
215
 
164
216
  /**
@@ -172,7 +224,11 @@ export function __resetHandlerCacheForTests(): void {
172
224
  */
173
225
  export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
174
226
  if (cachedDeps === undefined) {
175
- cachedDeps = buildDepsFromEnv();
227
+ if (cachedDepsPromise === undefined) {
228
+ cachedDepsPromise = buildDepsFromEnv();
229
+ }
230
+ cachedDeps = await cachedDepsPromise;
231
+ cachedDepsPromise = undefined;
176
232
  }
177
233
  const deps = cachedDeps;
178
234
  return handleSweep({ dynamo: deps.dynamo, tables: deps.tables });
@@ -190,7 +246,11 @@ export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
190
246
  */
191
247
  export async function pingCheckerHandler(_event: unknown): Promise<PingCheckResult> {
192
248
  if (cachedDeps === undefined) {
193
- cachedDeps = buildDepsFromEnv();
249
+ if (cachedDepsPromise === undefined) {
250
+ cachedDepsPromise = buildDepsFromEnv();
251
+ }
252
+ cachedDeps = await cachedDepsPromise;
253
+ cachedDepsPromise = undefined;
194
254
  }
195
255
  const deps = cachedDeps;
196
256
  return handlePingCheck({
@@ -212,13 +272,53 @@ export {
212
272
  type SweepResult,
213
273
  };
214
274
 
275
+ /**
276
+ * Lambda entry for the NLB TCP+TLS streaming target. Wired
277
+ * to the NLB target group in the CDK stack. Shares the cached
278
+ * {@link HandlerDeps} with the wss {@link handler} — same tables, same
279
+ * config, same management API (when available). Each NLB client chunk
280
+ * arrives as one invocation; the handler returns the outbound bytes for
281
+ * the NLB to stream back over the held-open TCP connection.
282
+ */
283
+ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStreamResponse> {
284
+ if (cachedDeps === undefined) {
285
+ if (cachedDepsPromise === undefined) {
286
+ cachedDepsPromise = buildDepsFromEnv();
287
+ }
288
+ cachedDeps = await cachedDepsPromise;
289
+ cachedDepsPromise = undefined;
290
+ }
291
+ const deps = cachedDeps;
292
+ return handleNlbStream(event, {
293
+ dynamo: deps.dynamo,
294
+ tables: deps.tables,
295
+ serverConfig: deps.serverConfig,
296
+ motd: deps.motd,
297
+ messages: deps.messages,
298
+ managementApi: deps.managementApi,
299
+ ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
300
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
301
+ });
302
+ }
303
+
304
+ export {
305
+ handleNlbStream,
306
+ deriveNlbConnectionId,
307
+ type NlbStreamEvent,
308
+ type NlbStreamParams,
309
+ type NlbStreamResponse,
310
+ };
311
+
215
312
  /**
216
313
  * Builds a {@link HandlerDeps} from the current `process.env`.
217
314
  *
218
315
  * Exposed so tests can construct production-shaped deps without re-running
219
- * the env parsing.
316
+ * the env parsing. Async because it scans the `Accounts` DynamoDB table
317
+ * (via {@link resolveAccountStore}) to pre-load SASL credentials at cold
318
+ * start — the {@link AccountStore} port is synchronous, so the scan must
319
+ * complete before any reducer calls `verify`.
220
320
  */
221
- export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerDeps {
321
+ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
222
322
  const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
223
323
  const tables = tablesConfigFromEnv(env);
224
324
  const region = typeof env.AWS_REGION === 'string' ? env.AWS_REGION : undefined;
@@ -231,7 +331,18 @@ export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerD
231
331
  }
232
332
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
233
333
  const messages = bindMessageStore(cfg);
234
- return { dynamo, tables, serverConfig: cfg, motd, managementApi, messages };
334
+ const history = new InMemoryNickHistoryStore(SystemClock);
335
+ const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
336
+ return {
337
+ dynamo,
338
+ tables,
339
+ serverConfig: cfg,
340
+ motd,
341
+ managementApi,
342
+ messages,
343
+ history,
344
+ ...(accounts !== undefined ? { accounts } : {}),
345
+ };
235
346
  }
236
347
 
237
348
  function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {