serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * CDK-coupled table definitions (PLAN §4).
3
+ *
4
+ * The single source of truth for the deployed table shapes
5
+ * (`partitionKey`, `sortKey`, `timeToLiveAttribute`, …). Lives in
6
+ * `aws-adapter` (not `aws-stack`) so the runtime can reference the same
7
+ * logical names via {@link TABLE_NAMES} without round-tripping through
8
+ * the app directory.
9
+ *
10
+ * Re-exported from `apps/aws-stack/src/aws-stack.ts`. NOT re-exported
11
+ * from `aws-adapter`'s `index.ts` so the Lambda handler bundle does
12
+ * not transitively include `aws-cdk-lib`.
13
+ *
14
+ * Schema invariants (PLAN §4):
15
+ * • `Nicks` uniqueness is enforced at write-time via conditional PutItem
16
+ * (PK = lowercased nick) — not by the schema itself.
17
+ * • `ChannelMembers` uses a composite key (channelName PK + connectionId SK)
18
+ * so a future `TransactWriteItems` can span Connections ↔ membership
19
+ * atomically. (Atomicity across Connections/ChannelMembers lands in the
20
+ * membership-transaction follow-up.)
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`.
26
+ * • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
27
+ * are reaped automatically in addition to the explicit sweeper.
28
+ */
29
+
30
+ import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
31
+ import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
+ import type { TableName } from './tables.js';
33
+
34
+ /**
35
+ * All five tables keyed by their logical construct id. The id doubles as the
36
+ * CloudFormation logical id; the physical `TableName` is set on each entry.
37
+ *
38
+ * NOTE: the deployed physical name is **environment-prefixed** by
39
+ * `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
40
+ * `tableName` here is the schema default overridden at synth time. Both
41
+ * the CDK stack and the runtime read from this record. Tests that prefix
42
+ * every physical name (to isolate parallel runs) consume the
43
+ * `TABLE_DEFS` entries' key/attribute names only.
44
+ */
45
+ export const TABLE_DEFS: Record<TableName, TableProps> = {
46
+ Connections: {
47
+ tableName: 'Connections',
48
+ partitionKey: { name: 'connectionId', type: AttributeType.STRING },
49
+ billingMode: BillingMode.PAY_PER_REQUEST,
50
+ timeToLiveAttribute: 'idleSince',
51
+ },
52
+ ChannelMeta: {
53
+ tableName: 'ChannelMeta',
54
+ partitionKey: { name: 'channelName', type: AttributeType.STRING },
55
+ billingMode: BillingMode.PAY_PER_REQUEST,
56
+ },
57
+ ChannelMembers: {
58
+ tableName: 'ChannelMembers',
59
+ partitionKey: { name: 'channelName', type: AttributeType.STRING },
60
+ sortKey: { name: 'connectionId', type: AttributeType.STRING },
61
+ billingMode: BillingMode.PAY_PER_REQUEST,
62
+ },
63
+ Nicks: {
64
+ tableName: 'Nicks',
65
+ partitionKey: { name: 'nickLower', type: AttributeType.STRING },
66
+ billingMode: BillingMode.PAY_PER_REQUEST,
67
+ },
68
+ Accounts: {
69
+ tableName: 'Accounts',
70
+ partitionKey: { name: 'account', type: AttributeType.STRING },
71
+ billingMode: BillingMode.PAY_PER_REQUEST,
72
+ },
73
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Lambda env config loader.
3
+ *
4
+ * Mirrors the Cloudflare (`cf-adapter/src/config-loader.ts`) and local-cli
5
+ * (`local-cli/src/config-loader.ts`) adapters: takes platform-native env
6
+ * vars and feeds them through the shared {@link parseServerConfig} so a
7
+ * single boot-time failure mode covers every adapter.
8
+ *
9
+ * Lambda env vars are all strings; numeric knobs are coerced here.
10
+ */
11
+
12
+ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
13
+
14
+ /**
15
+ * The subset of `process.env` this loader reads. Optional members are
16
+ * omitted from the input object when undefined so the schema's defaults
17
+ * apply.
18
+ */
19
+ export interface LambdaConfigEnv {
20
+ SERVER_NAME?: string;
21
+ NETWORK_NAME?: string;
22
+ MOTD?: string;
23
+ MAX_CLIENTS?: string;
24
+ CHANNEL_PREFIXES?: string;
25
+ OPER_USER?: string;
26
+ OPER_PASSWORD?: string;
27
+ MAX_CHANNELS_PER_USER?: string;
28
+ MAX_TARGETS_PER_COMMAND?: string;
29
+ NICK_LEN?: string;
30
+ CHANNEL_LEN?: string;
31
+ TOPIC_LEN?: string;
32
+ MAX_LIST_ENTRIES?: string;
33
+ QUIT_MESSAGE?: string;
34
+ /**
35
+ * SASL PLAIN accounts. Newline-delimited `username:password` pairs
36
+ * (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
37
+ * the `saslAccounts` config field so {@link bindAccountStore} can seed
38
+ * an `InMemoryAccountStore` at boot.
39
+ */
40
+ SASL_ACCOUNTS?: string;
41
+ }
42
+
43
+ /**
44
+ * Builds the input object the shared schema expects from a Lambda env.
45
+ *
46
+ * Exported so the assemble→parse boundary is unit-testable in isolation.
47
+ * Mirrors `cf-adapter`'s `buildConfigInput` line-for-line except for the
48
+ * MOTD env-var name (`MOTD` here vs `MOTD_LINES` in CF — Lambda env vars
49
+ * conventionally use bare names).
50
+ */
51
+ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unknown> {
52
+ const input: Record<string, unknown> = {
53
+ serverName: env.SERVER_NAME,
54
+ networkName: env.NETWORK_NAME,
55
+ };
56
+ if (env.MOTD !== undefined && env.MOTD.length > 0) {
57
+ input.motdLines = env.MOTD.split('\n');
58
+ }
59
+ if (env.MAX_CLIENTS !== undefined) {
60
+ input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
61
+ }
62
+ if (env.CHANNEL_PREFIXES !== undefined) {
63
+ input.channelPrefixes = env.CHANNEL_PREFIXES;
64
+ }
65
+ if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
66
+ input.operCreds = [
67
+ {
68
+ user: env.OPER_USER ?? '',
69
+ password: env.OPER_PASSWORD ?? '',
70
+ },
71
+ ];
72
+ }
73
+ if (env.MAX_CHANNELS_PER_USER !== undefined) {
74
+ input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
75
+ }
76
+ if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
77
+ input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
78
+ }
79
+ if (env.NICK_LEN !== undefined) {
80
+ input.nickLen = Number.parseInt(env.NICK_LEN, 10);
81
+ }
82
+ if (env.CHANNEL_LEN !== undefined) {
83
+ input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
84
+ }
85
+ if (env.TOPIC_LEN !== undefined) {
86
+ input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
87
+ }
88
+ if (env.MAX_LIST_ENTRIES !== undefined) {
89
+ input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
90
+ }
91
+ if (env.QUIT_MESSAGE !== undefined) {
92
+ input.quitMessage = env.QUIT_MESSAGE;
93
+ }
94
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
95
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
96
+ }
97
+ return input;
98
+ }
99
+
100
+ /**
101
+ * Parses a newline-delimited `username:password` string into the config's
102
+ * `saslAccounts` shape. Malformed entries (missing colon, empty fields)
103
+ * are skipped so a trailing newline or partial edit does not break boot.
104
+ */
105
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
106
+ const out: Array<{ username: string; password: string }> = [];
107
+ for (const line of raw.split('\n')) {
108
+ const trimmed = line.trim();
109
+ if (trimmed.length === 0) continue;
110
+ const sep = trimmed.indexOf(':');
111
+ if (sep <= 0) continue;
112
+ const username = trimmed.slice(0, sep);
113
+ const password = trimmed.slice(sep + 1);
114
+ if (username.length === 0 || password.length === 0) continue;
115
+ out.push({ username, password });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Loads and validates the server config from a Lambda env. Throws a
122
+ * readable `Error` listing every invalid field on failure.
123
+ */
124
+ export function loadServerConfigFromLambdaEnv(env: LambdaConfigEnv): ParsedServerConfig {
125
+ return parseServerConfig(buildLambdaConfigInput(env));
126
+ }
@@ -0,0 +1,156 @@
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
+
21
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
22
+ import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
23
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
24
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
+ import type { AccountStore, SaslPayload, SaslResult } from '@serverless-ircd/irc-core';
26
+
27
+ const SCRYPT_KEY_LEN = 64;
28
+ const SALT_LEN = 16;
29
+
30
+ /**
31
+ * A single SASL PLAIN credential stored as a scrypt hash.
32
+ *
33
+ * Every field is a DynamoDB attribute — this object IS the table row
34
+ * (plus the PK `account`). `algorithm` is retained so future migrations
35
+ * to Argon2/bcrypt can be detected and handled gracefully.
36
+ */
37
+ export interface HashedAccountCredential {
38
+ readonly account: string;
39
+ readonly algorithm: 'scrypt';
40
+ readonly salt: string;
41
+ readonly hash: string;
42
+ }
43
+
44
+ /**
45
+ * Hashes a plaintext password into a {@link HashedAccountCredential}
46
+ * suitable for writing to the `Accounts` table via `putAccountCredential`.
47
+ *
48
+ * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
49
+ * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
50
+ * override the default 64-byte derived-key length.
51
+ */
52
+ export function hashAccountCredential(
53
+ username: string,
54
+ password: string,
55
+ opts?: { salt?: Buffer; keyLen?: number },
56
+ ): HashedAccountCredential {
57
+ const salt = opts?.salt ?? randomBytes(SALT_LEN);
58
+ const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
59
+ const hash = scryptSync(password, salt, keyLen);
60
+ return {
61
+ account: username,
62
+ algorithm: 'scrypt',
63
+ salt: salt.toString('base64'),
64
+ hash: hash.toString('base64'),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Verifies a plaintext password against a {@link HashedAccountCredential}
70
+ * using `timingSafeEqual` (no short-circuit on first mismatched byte).
71
+ *
72
+ * Returns `false` for malformed entries rather than throwing — a corrupt
73
+ * row should not crash the SASL exchange.
74
+ */
75
+ export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
76
+ if (entry.algorithm !== 'scrypt') return false;
77
+ let salt: Buffer;
78
+ let expected: Buffer;
79
+ try {
80
+ salt = Buffer.from(entry.salt, 'base64');
81
+ expected = Buffer.from(entry.hash, 'base64');
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (expected.length === 0) return false;
86
+ const computed = scryptSync(password, salt, expected.length);
87
+ return computed.length === expected.length && timingSafeEqual(computed, expected);
88
+ }
89
+
90
+ /**
91
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed
92
+ * credentials. Construct with entries from {@link loadDynamoAccountStore}
93
+ * (or {@link hashAccountCredential} for unit tests).
94
+ */
95
+ export class DynamoAccountStore implements AccountStore {
96
+ private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
97
+
98
+ constructor(entries: ReadonlyArray<HashedAccountCredential>) {
99
+ this.entries = new Map(entries.map((e) => [e.account, e]));
100
+ }
101
+
102
+ verify(mech: string, payload: SaslPayload): SaslResult {
103
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
104
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
105
+ }
106
+ const entry = this.entries.get(payload.username);
107
+ if (entry === undefined) {
108
+ return { ok: false, reason: 'invalid credentials' };
109
+ }
110
+ if (!verifyHashedPassword(payload.password, entry)) {
111
+ return { ok: false, reason: 'invalid credentials' };
112
+ }
113
+ return { ok: true, account: payload.username };
114
+ }
115
+
116
+ /** Number of loaded accounts (diagnostics / logging). */
117
+ get size(): number {
118
+ return this.entries.size;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Scans the `Accounts` table and returns a fully-populated
124
+ * {@link DynamoAccountStore}, or `undefined` when the table has no rows.
125
+ *
126
+ * Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
127
+ * scan page are fully loaded. Rows missing required attributes are
128
+ * silently skipped.
129
+ */
130
+ export async function loadDynamoAccountStore(
131
+ docClient: DynamoDBDocumentClient,
132
+ tableName: string,
133
+ ): Promise<DynamoAccountStore | undefined> {
134
+ const entries: HashedAccountCredential[] = [];
135
+ let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
136
+ do {
137
+ const result: ScanCommandOutput = await docClient.send(
138
+ new ScanCommand({
139
+ TableName: tableName,
140
+ ExclusiveStartKey: exclusiveStartKey,
141
+ }),
142
+ );
143
+ for (const item of result.Items ?? []) {
144
+ const account = typeof item.account === 'string' ? item.account : undefined;
145
+ const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
146
+ const salt = typeof item.salt === 'string' ? item.salt : undefined;
147
+ const hash = typeof item.hash === 'string' ? item.hash : undefined;
148
+ if (account !== undefined && algorithm !== undefined && salt !== undefined && hash !== undefined) {
149
+ entries.push({ account, algorithm: algorithm as HashedAccountCredential['algorithm'], salt, hash });
150
+ }
151
+ }
152
+ exclusiveStartKey = result.LastEvaluatedKey;
153
+ } while (exclusiveStartKey !== undefined);
154
+ if (entries.length === 0) return undefined;
155
+ return new DynamoAccountStore(entries);
156
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * DynamoDB DocumentClient factory.
3
+ *
4
+ * Centralised so the runtime, the handlers, and the test harness all
5
+ * build their clients with the same marshalling conventions
6
+ * (`removeUndefinedValues: true`, native JS values in/out).
7
+ */
8
+
9
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
10
+ import {
11
+ DynamoDBDocumentClient,
12
+ type TranslateConfig,
13
+ type marshallOptions,
14
+ type unmarshallOptions,
15
+ } from '@aws-sdk/lib-dynamodb';
16
+
17
+ /** Default marshalling options — every caller uses these. */
18
+ const DEFAULT_TRANSLATE: TranslateConfig = {
19
+ marshallOptions: {
20
+ removeUndefinedValues: true,
21
+ convertClassInstanceToMap: false,
22
+ } as marshallOptions,
23
+ unmarshallOptions: {} as unmarshallOptions,
24
+ };
25
+
26
+ /**
27
+ * Builds a {@link DynamoDBDocumentClient} pointed at `endpoint`
28
+ * (or AWS proper when `endpoint` is omitted).
29
+ *
30
+ * Tests pass `endpoint` so they can address DynamoDB Local; production
31
+ * constructs the client with no endpoint and lets the SDK resolve the
32
+ * region from the Lambda execution role.
33
+ *
34
+ * `region` defaults to `'us-east-1'` when unset. DynamoDB Local ignores
35
+ * the value but the SDK requires one to be present so it can build the
36
+ * SigV4 signature. Production deployments inject a real region via the
37
+ * Lambda execution environment (`AWS_REGION`).
38
+ *
39
+ * When `endpoint` is supplied (the documented signal for DynamoDB Local
40
+ * / test mode) dummy static credentials are injected. The SDK v3 signs
41
+ * every request with SigV4 even against a local endpoint, so without
42
+ * credentials the default provider chain fails with
43
+ * `CredentialsProviderError`. Production never passes an endpoint and
44
+ * therefore resolves real credentials from the Lambda execution role.
45
+ */
46
+ export function createDynamoDocumentClient(opts: {
47
+ endpoint?: string;
48
+ region?: string;
49
+ }): DynamoDBDocumentClient {
50
+ const region = opts.region ?? process.env.AWS_REGION ?? 'us-east-1';
51
+ const client = new DynamoDBClient({
52
+ region,
53
+ ...(opts.endpoint !== undefined
54
+ ? {
55
+ endpoint: opts.endpoint,
56
+ credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
57
+ }
58
+ : {}),
59
+ });
60
+ return DynamoDBDocumentClient.from(client, DEFAULT_TRANSLATE);
61
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `$connect` handler — Lambda entry for `event.requestContext.routeKey === '$connect'`.
3
+ *
4
+ * Persists a fresh `Connections` row keyed by the APIGW connection id.
5
+ * Subsequent `$default` invocations load this row, run the actor, and
6
+ * persist the mutated state back.
7
+ */
8
+
9
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
10
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
11
+ import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
12
+ import { marshalConnection } from '../serialize.js';
13
+ import type { TablesConfig } from '../tables.js';
14
+ import { createInitialConnectionState } from './state.js';
15
+
16
+ /** Parameters accepted by {@link handleConnect}. */
17
+ export interface ConnectParams {
18
+ dynamo: DynamoDBDocumentClient;
19
+ tables: TablesConfig;
20
+ connectionId: string;
21
+ /** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
22
+ now?: number;
23
+ /** Reserved for future use; required so the signature mirrors `$default`. */
24
+ serverConfig?: ParsedServerConfig;
25
+ }
26
+
27
+ /**
28
+ * Inserts the Connections row for a fresh WebSocket upgrade.
29
+ *
30
+ * Idempotent: APIGW retries `$connect` on transient failures, so we
31
+ * use `PutCommand` (overwrites) rather than a conditional insert.
32
+ */
33
+ export async function handleConnect(params: ConnectParams): Promise<void> {
34
+ const now = params.now ?? Date.now();
35
+ const state = createInitialConnectionState(params.connectionId, now);
36
+ void params.serverConfig; // reserved for max-clients gating (later ticket)
37
+ const row = marshalConnection(state, now);
38
+ await params.dynamo.send(
39
+ new PutCommand({
40
+ TableName: params.tables.Connections,
41
+ Item: row,
42
+ }),
43
+ );
44
+ }