serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,25 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Seed SASL PLAIN accounts into the DynamoDB `Accounts` table.
3
+ * Seed SASL PLAIN accounts into the DynamoDB `Services` table (the unified
4
+ * services credential home).
5
+ *
6
+ * Repointed from the legacy `Accounts` table to the `Services` table —
7
+ * `Services` (`NICK:<fold>` partition) is the single credential home for
8
+ * SASL PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded
9
+ * accounts verify via `services.verifyNick` end-to-end.
4
10
  *
5
11
  * Usage:
6
12
  * node --import tsx tools/seed-aws-accounts.ts \
7
- * --table StagingAccounts \
13
+ * --table StagingServices \
8
14
  * --endpoint http://localhost:8000 \
9
15
  * --accounts alice:s3cret bob:password2
10
16
  *
11
17
  * Or read from a file (newline-delimited `username:password`):
12
18
  * node --import tsx tools/seed-aws-accounts.ts \
13
- * --table StagingAccounts \
19
+ * --table StagingServices \
14
20
  * --file accounts.txt
15
21
  *
16
- * The script hashes each password with scrypt (never writes plaintext).
17
- * Re-running with the same username overwrites the prior row.
22
+ * The script hashes each password with scrypt (never writes plaintext) and
23
+ * writes via a conditional PutItem that never clobbers an existing nick
24
+ * registration (a NickServ-registered nick whose password was set by the
25
+ * user wins over the seed).
18
26
  */
19
27
 
20
28
  import { readFileSync } from 'node:fs';
21
29
  import { parseArgs } from 'node:util';
22
- import { createDynamoDocumentClient, putAccountCredential } from '@serverless-ircd/aws-adapter';
30
+ import { createDynamoDocumentClient, loadDynamoServicesStore } from '@serverless-ircd/aws-adapter';
31
+ import { SystemClock } from '@serverless-ircd/irc-core';
23
32
 
24
33
  const { values } = parseArgs({
25
34
  options: {
@@ -33,7 +42,7 @@ const { values } = parseArgs({
33
42
 
34
43
  if (values.table === undefined) {
35
44
  console.error(
36
- 'Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
45
+ 'Usage: seed-aws-accounts --table <ServicesTableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
37
46
  );
38
47
  process.exit(1);
39
48
  }
@@ -71,9 +80,25 @@ if (pairs.length === 0) {
71
80
  process.exit(1);
72
81
  }
73
82
 
83
+ // Load the services store against the target table, then register each
84
+ // account. `registerNick` is idempotent: it returns `{ ok: false }` when
85
+ // the nick already exists and does NOT overwrite the credential.
86
+ const store = await loadDynamoServicesStore(docClient, values.table, SystemClock);
87
+ if (store === undefined) {
88
+ console.error(`Could not load services store from table ${values.table}.`);
89
+ process.exit(1);
90
+ }
91
+
74
92
  for (const { username, password } of pairs) {
75
- const entry = await putAccountCredential(docClient, values.table, username, password);
76
- console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
93
+ const result = store.registerNick(username, password, '');
94
+ if (result.ok) {
95
+ console.log(`Seeded ${username}`);
96
+ } else {
97
+ console.log(`Skipped ${username} (already registered)`);
98
+ }
77
99
  }
78
100
 
79
- console.log(`Done: ${pairs.length} account(s) written to ${values.table}.`);
101
+ // Flush the write-behind queue so the registrations land in DynamoDB.
102
+ await store.flush();
103
+
104
+ console.log(`Done: ${pairs.length} account(s) processed against ${values.table}.`);
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Seed SASL PLAIN accounts into a Cloudflare D1 `accounts` table.
3
+ * Seed SASL PLAIN accounts into a Cloudflare D1 `nickserv_accounts` table.
4
+ *
5
+ * Repointed from the legacy `accounts` table to the unified services table
6
+ * — `nickserv_accounts` is the single credential home for SASL PLAIN, SASL
7
+ * EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded accounts verify via
8
+ * `services.verifyNick` end-to-end.
4
9
  *
5
10
  * Usage:
6
11
  * node --import tsx tools/seed-cf-accounts.ts \
@@ -17,9 +22,10 @@
17
22
  * --file accounts.txt
18
23
  *
19
24
  * The script hashes each password with scrypt (never writes plaintext) and
20
- * executes the resulting `INSERT OR REPLACE` via `wrangler d1 execute`.
21
- * Re-running with the same username overwrites the prior row. The `accounts`
22
- * table is created (IF NOT EXISTS) on the first run.
25
+ * executes the resulting `INSERT OR IGNORE` via `wrangler d1 execute`.
26
+ * `INSERT OR IGNORE` is idempotent: re-running with the same username does
27
+ * NOT overwrite an existing row (a NickServ-registered nick whose password
28
+ * was set by the user wins over the seed).
23
29
  *
24
30
  * Flags:
25
31
  * --database D1 database name (as declared in wrangler.toml). Required.
@@ -33,10 +39,12 @@
33
39
  import { execFileSync } from 'node:child_process';
34
40
  import { readFileSync } from 'node:fs';
35
41
  import { parseArgs } from 'node:util';
42
+ import { CREATE_NICKSERV_ACCOUNTS_SQL } from '@serverless-ircd/cf-adapter';
36
43
  import {
37
- CREATE_ACCOUNTS_TABLE_SQL,
38
- buildAccountPutStatement,
39
- } from '@serverless-ircd/cf-adapter/accounts';
44
+ type HashedAccountCredential,
45
+ caseFold,
46
+ hashAccountCredential,
47
+ } from '@serverless-ircd/irc-core';
40
48
 
41
49
  const { values } = parseArgs({
42
50
  options: {
@@ -96,10 +104,35 @@ function runD1Execute(sql: string): void {
96
104
  }
97
105
 
98
106
  // Ensure the table exists (idempotent).
99
- runD1Execute(CREATE_ACCOUNTS_TABLE_SQL);
107
+ runD1Execute(CREATE_NICKSERV_ACCOUNTS_SQL);
108
+
109
+ /**
110
+ * Builds a self-contained `INSERT OR IGNORE` SQL statement that writes a
111
+ * scrypt-hashed `nickserv_accounts` row.
112
+ *
113
+ * `INSERT OR IGNORE` (not `INSERT OR REPLACE`) ensures the seed NEVER
114
+ * clobbers an existing registration — a NickServ-registered nick or a
115
+ * prior seed's password wins. The row carries an empty email, the default
116
+ * `'none'` enforcement policy, `cert_subjects = '[]'`, and the scrypt
117
+ * credential derived from the plaintext password.
118
+ */
119
+ export function buildNickServPutStatement(
120
+ username: string,
121
+ password: string,
122
+ ): { entry: HashedAccountCredential; sql: string } {
123
+ const entry = hashAccountCredential(username, password);
124
+ const nickKey = caseFold('rfc1459', username);
125
+ const sql = `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
126
+ return { entry, sql };
127
+ }
128
+
129
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
130
+ function sqlEscape(value: string): string {
131
+ return value.replace(/'/g, "''");
132
+ }
100
133
 
101
134
  for (const { username, password } of pairs) {
102
- const { entry, sql } = buildAccountPutStatement(username, password);
135
+ const { entry, sql } = buildNickServPutStatement(username, password);
103
136
  runD1Execute(sql);
104
137
  console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
105
138
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,40 @@
1
+ import type { Socket } from 'node:net';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ /**
5
+ * Dedicated coverage for the `server.close` error-rejection branch in
6
+ * forwarder close() (forwarder.ts:135): `server.close((err) => err ?
7
+ * reject(err) : resolve())`. Under normal operation the callback never
8
+ * surfaces an error, so the truthy branch is unreachable without a stub.
9
+ *
10
+ * This file mocks `node:net` so the forwarder's TCP listener gets a server
11
+ * whose `close` invokes its callback with a synthetic Error. The mock is
12
+ * file-scoped (vi.mock), leaving the real-socket integration suite in
13
+ * forwarder.test.ts untouched.
14
+ */
15
+
16
+ vi.mock('node:net', async (importActual) => {
17
+ const actual = await importActual<typeof import('node:net')>();
18
+ const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
19
+ const server = actual.createServer(connectionListener);
20
+ const realClose = server.close.bind(server);
21
+ // Drive the real shutdown so the listener is actually torn down, but
22
+ // surface a synthetic error to the callback to exercise the
23
+ // error-rejection branch.
24
+ server.close = ((cb?: (err?: Error | null) => void) => {
25
+ realClose(() => cb?.(new Error('synthetic server.close failure')));
26
+ return server;
27
+ }) as unknown as typeof server.close;
28
+ return server;
29
+ };
30
+ return { ...actual, createServer: wrappedCreateServer as unknown as typeof actual.createServer };
31
+ });
32
+
33
+ const { startForwarder } = await import('../src/forwarder');
34
+
35
+ describe('forwarder close() — server.close error branch', () => {
36
+ it('rejects when server.close surfaces an error to its callback', async () => {
37
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
38
+ await expect(fwd.close()).rejects.toThrow('synthetic server.close failure');
39
+ });
40
+ });
@@ -0,0 +1,78 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Socket } from 'node:net';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ /**
6
+ * Coverage for two defensive branches in forwarder.ts that cannot be reached
7
+ * through real sockets:
8
+ * - line 148: `server.address()` always returns an AddressInfo after the
9
+ * 'listening' event fires, so the `opts.listenPort` ternary fallback is
10
+ * never taken in practice.
11
+ * - line 184: a connected server-side socket always carries remoteAddress/
12
+ * remotePort, so the `?? '?'` remote-label fallbacks are never taken.
13
+ *
14
+ * Both are exercised here by mocking `node:net` so the forwarder's TCP server
15
+ * reports a null address and its connection listener is captured, allowing a
16
+ * synthetic address-less socket to be fed in. The mock is file-scoped
17
+ * (vi.mock); the real-socket integration suite in forwarder.test.ts is
18
+ * untouched.
19
+ */
20
+
21
+ const state = vi.hoisted(() => ({
22
+ captured: undefined as ((socket: Socket) => void) | undefined,
23
+ }));
24
+
25
+ vi.mock('node:net', async (importActual) => {
26
+ const actual = await importActual<typeof import('node:net')>();
27
+ const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
28
+ state.captured = connectionListener;
29
+ const server = actual.createServer(connectionListener);
30
+ // Force the defensive AddressInfo fallback (forwarder.ts:148).
31
+ server.address = (() => null) as typeof server.address;
32
+ return server;
33
+ };
34
+ return {
35
+ ...actual,
36
+ createServer: wrappedCreateServer as unknown as typeof actual.createServer,
37
+ };
38
+ });
39
+
40
+ const { startForwarder } = await import('../src/forwarder');
41
+
42
+ const openForwarders: { close(): Promise<void> }[] = [];
43
+ afterEach(async () => {
44
+ for (const f of openForwarders.splice(0)) {
45
+ await f.close().catch(() => undefined);
46
+ }
47
+ });
48
+
49
+ describe('forwarder — defensive branches unreachable via real sockets', () => {
50
+ it('falls back to opts.listenPort when server.address() yields no AddressInfo', async () => {
51
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
52
+ openForwarders.push(fwd);
53
+ // server.address() returns null → the ternary takes its false branch and
54
+ // the reported port is the requested opts.listenPort (0), not a real
55
+ // ephemeral port.
56
+ expect(fwd.listenPort).toBe(0);
57
+ });
58
+
59
+ it('builds the remote label with "?" placeholders for an address-less socket', async () => {
60
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
61
+ openForwarders.push(fwd);
62
+
63
+ // Synthesize an incoming connection on a socket with no remote address/
64
+ // port; bridgeConnection must build the `?:?` label (forwarder.ts:184)
65
+ // and register the bridge without throwing.
66
+ const fakeSocket = new EventEmitter();
67
+ Object.assign(fakeSocket, {
68
+ remoteAddress: undefined,
69
+ remotePort: undefined,
70
+ destroy: () => undefined,
71
+ });
72
+ const listener = state.captured;
73
+ if (listener === undefined) throw new Error('connection listener was not captured');
74
+ listener(fakeSocket as unknown as Socket);
75
+
76
+ expect([...fwd.testSockets]).toContain(fakeSocket);
77
+ });
78
+ });
@@ -735,3 +735,54 @@ describe('forwarder — logging', () => {
735
735
  infoSpy.mockRestore();
736
736
  });
737
737
  });
738
+
739
+ describe('forwarder — empty-line drop (TCP→WS guard)', () => {
740
+ it('drops a bare-CRLF empty line and forwards only the next non-empty line', async () => {
741
+ const target = await startTarget();
742
+ openServers.push(target);
743
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
744
+ openServers.push(fwd);
745
+
746
+ const client = new TcpClient(fwd.listenPort);
747
+ openClients.push(client);
748
+ await client.opened();
749
+
750
+ // A bare CRLF yields an empty line from the LineScanner; the
751
+ // `line.length > 0` guard drops it, so only the subsequent real line is
752
+ // bridged as a single WS frame.
753
+ client.sendRaw('\r\n');
754
+ client.send('PING :after-empty');
755
+
756
+ const ws = await target.waitForConnection();
757
+ await new Promise<void>((res) => ws.once('message', () => res()));
758
+ expect(target.frames).toEqual(['PING :after-empty']);
759
+ });
760
+ });
761
+
762
+ describe('forwarder — post-teardown error suppression', () => {
763
+ it('suppresses a tcp socket error arriving after the bridge has torn down', async () => {
764
+ const target = await startTarget();
765
+ openServers.push(target);
766
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
767
+ openServers.push(fwd);
768
+
769
+ const client = new TcpClient(fwd.listenPort);
770
+ openClients.push(client);
771
+ await client.opened();
772
+ await target.waitForConnection();
773
+
774
+ const serverSide = [...fwd.testSockets][0];
775
+ if (serverSide === undefined) throw new Error('no server-side socket');
776
+
777
+ // Tearing the bridge down arms the `bridgeClosed` guard; a tcp error
778
+ // arriving afterwards (e.g. an RST echoing our own destroy()) is
779
+ // expected and must be silently suppressed.
780
+ await fwd.close();
781
+
782
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
783
+ serverSide.emit('error', new Error('post-teardown noise'));
784
+ await new Promise((r) => setImmediate(r));
785
+ expect(errSpy).not.toHaveBeenCalled();
786
+ errSpy.mockRestore();
787
+ });
788
+ });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { ConsoleLogger, LogLevel, parseLogLevel } from '../src/logger';
3
3
 
4
4
  /**
@@ -134,3 +134,33 @@ describe('ConsoleLogger — output shape', () => {
134
134
  expect(JSON.parse(lines.error[0] as string).msg).toBe('e');
135
135
  });
136
136
  });
137
+
138
+ describe('ConsoleLogger — global console sinks', () => {
139
+ it('routes a warn record to the global console.warn sink when no sinks are injected', () => {
140
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
141
+ const log = new ConsoleLogger(undefined, LogLevel.Warn);
142
+ log.warn('global sink warn', { k: 'v' });
143
+ expect(warnSpy).toHaveBeenCalledTimes(1);
144
+ const rec = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? '{}'));
145
+ expect(rec.msg).toBe('global sink warn');
146
+ warnSpy.mockRestore();
147
+ });
148
+
149
+ it('falls back to no-op drop-sinks when globalThis.console is undefined', () => {
150
+ const original = globalThis.console;
151
+ (globalThis as { console?: Console | undefined }).console = undefined;
152
+ try {
153
+ // globalConsoleSinks() takes the `console === undefined` branch and
154
+ // returns drop-sinks; every level must swallow without throwing.
155
+ const log = new ConsoleLogger(undefined, LogLevel.Debug);
156
+ expect(() => {
157
+ log.debug('d');
158
+ log.info('i');
159
+ log.warn('w');
160
+ log.error('e');
161
+ }).not.toThrow();
162
+ } finally {
163
+ (globalThis as { console?: Console | undefined }).console = original;
164
+ }
165
+ });
166
+ });
@@ -1,121 +0,0 @@
1
- /**
2
- * SASL account store construction + resolution for the AWS adapter.
3
- *
4
- * Centralises `AccountStore` creation so the actor-construction site
5
- * (`handleDefault`) binds a single helper rather than constructing the
6
- * store inline. Two sources of credentials are reconciled here:
7
- *
8
- * 1. The DynamoDB `Accounts` table (authoritative when populated) —
9
- * loaded at cold start into a {@link DynamoAccountStore} by
10
- * {@link resolveAccountStore}. Credentials are stored as scrypt
11
- * hashes, never plaintext.
12
- * 2. The parsed server config (`saslAccounts`, sourced from the
13
- * `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
14
- * when the table is empty, preserving the config-seed behaviour for
15
- * deployments that have not migrated to the table.
16
- *
17
- * The resolved store is scoped to the Lambda execution context: it
18
- * persists across warm invocations (via the memoised `HandlerDeps`) but
19
- * is rebuilt on cold start, picking up account changes made since the
20
- * last cold start.
21
- */
22
-
23
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
24
- import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
- import {
26
- type AccountStore,
27
- InMemoryAccountStore,
28
- type ParsedServerConfig,
29
- } from '@serverless-ircd/irc-core';
30
- import {
31
- DynamoAccountStore,
32
- type HashedAccountCredential,
33
- hashAccountCredential,
34
- loadDynamoAccountStore,
35
- } from './dynamo-account-store.js';
36
-
37
- // Re-export so callers can import everything from one module.
38
- export {
39
- DynamoAccountStore,
40
- type HashedAccountCredential,
41
- hashAccountCredential,
42
- loadDynamoAccountStore,
43
- };
44
-
45
- /**
46
- * Constructs the config-seeded in-memory account store (the legacy
47
- * fallback). Returns `undefined` when no accounts are configured so the
48
- * actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
49
- * `AUTHENTICATE PLAIN` → `904`).
50
- *
51
- * @param serverConfig Parsed config carrying the `saslAccounts` seed list.
52
- */
53
- export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
54
- if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
55
- return undefined;
56
- }
57
- return new InMemoryAccountStore(serverConfig.saslAccounts);
58
- }
59
-
60
- /**
61
- * Resolves the SASL account store for a deployment, applying the
62
- * table-then-config precedence.
63
- *
64
- * 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
65
- * credential into a {@link DynamoAccountStore}). When the table has
66
- * one or more rows it is authoritative — the table wins and the
67
- * config seed is ignored.
68
- * 2. When the table is empty, falls back to {@link bindAccountStore}
69
- * (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
70
- * have not migrated to the table keep working unchanged.
71
- * 3. Returns `undefined` when neither source has accounts (the default)
72
- * so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
73
- *
74
- * Scan failures (table not found, network error) are swallowed and
75
- * treated as an empty table, falling through to the config seed.
76
- *
77
- * @param docClient DynamoDB client addressed at the deployment's tables.
78
- * @param tableName Physical name of the `Accounts` table for this env.
79
- * @param serverConfig Parsed config (the config-seed fallback source).
80
- */
81
- export async function resolveAccountStore(
82
- docClient: DynamoDBDocumentClient,
83
- tableName: string,
84
- serverConfig?: ParsedServerConfig,
85
- ): Promise<AccountStore | undefined> {
86
- try {
87
- const store = await loadDynamoAccountStore(docClient, tableName);
88
- if (store !== undefined) return store;
89
- } catch {
90
- // Table might not exist or be unreachable; fall through to config seed.
91
- }
92
- return bindAccountStore(serverConfig);
93
- }
94
-
95
- /**
96
- * Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
97
- *
98
- * The password is hashed via {@link hashAccountCredential} (scrypt,
99
- * random salt) and stored as a {@link HashedAccountCredential} row —
100
- * plaintext is never written. A repeat call for the same `username`
101
- * overwrites the prior row (the PK is `account`), so this is the
102
- * canonical CRUD/seed entry point for oper and SASL account
103
- * provisioning.
104
- *
105
- * @returns The stored {@link HashedAccountCredential} (for tooling/tests).
106
- */
107
- export async function putAccountCredential(
108
- docClient: DynamoDBDocumentClient,
109
- tableName: string,
110
- username: string,
111
- password: string,
112
- ): Promise<HashedAccountCredential> {
113
- const entry = hashAccountCredential(username, password);
114
- await docClient.send(
115
- new PutCommand({
116
- TableName: tableName,
117
- Item: entry,
118
- }),
119
- );
120
- return entry;
121
- }
@@ -1,95 +0,0 @@
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
- }