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,4 @@
1
+ {
2
+ "_comment": "Fixture: Stryker config without a mutate[] array. Used by the validator test to confirm readStrykerConfig throws loud.",
3
+ "testRunner": "vitest"
4
+ }
@@ -0,0 +1,177 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ MUTATION_SCORE_THRESHOLD,
6
+ readCoverageThresholds,
7
+ readStrykerConfig,
8
+ readWorkflowJob,
9
+ } from '../src/validate.js';
10
+
11
+ const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
12
+
13
+ function repoPath(rel: string): string {
14
+ return resolve(REPO_ROOT, rel);
15
+ }
16
+
17
+ function readText(rel: string): string {
18
+ return readFileSync(repoPath(rel), 'utf8');
19
+ }
20
+
21
+ describe('coverage gate', () => {
22
+ it('enforces 100% line/function/branch/statement coverage on irc-core', () => {
23
+ const thresholds = readCoverageThresholds(repoPath('packages/irc-core/vitest.config.ts'));
24
+ expect(thresholds).toEqual({
25
+ lines: 100,
26
+ functions: 100,
27
+ branches: 100,
28
+ statements: 100,
29
+ });
30
+ });
31
+
32
+ it('enforces ≥90% coverage on cf-adapter', () => {
33
+ const thresholds = readCoverageThresholds(repoPath('packages/cf-adapter/vitest.config.ts'));
34
+ expect(thresholds.lines).toBeGreaterThanOrEqual(90);
35
+ expect(thresholds.functions).toBeGreaterThanOrEqual(90);
36
+ expect(thresholds.branches).toBeGreaterThanOrEqual(90);
37
+ expect(thresholds.statements).toBeGreaterThanOrEqual(90);
38
+ });
39
+
40
+ it('enforces ≥90% coverage on irc-server', () => {
41
+ const thresholds = readCoverageThresholds(repoPath('packages/irc-server/vitest.config.ts'));
42
+ expect(thresholds.lines).toBeGreaterThanOrEqual(90);
43
+ expect(thresholds.functions).toBeGreaterThanOrEqual(90);
44
+ expect(thresholds.branches).toBeGreaterThanOrEqual(90);
45
+ expect(thresholds.statements).toBeGreaterThanOrEqual(90);
46
+ });
47
+
48
+ it('enforces ≥90% coverage on in-memory-runtime', () => {
49
+ const thresholds = readCoverageThresholds(
50
+ repoPath('packages/in-memory-runtime/vitest.config.ts'),
51
+ );
52
+ expect(thresholds.lines).toBeGreaterThanOrEqual(90);
53
+ expect(thresholds.functions).toBeGreaterThanOrEqual(90);
54
+ expect(thresholds.branches).toBeGreaterThanOrEqual(90);
55
+ expect(thresholds.statements).toBeGreaterThanOrEqual(90);
56
+ });
57
+ });
58
+
59
+ describe('contract tests on every PR', () => {
60
+ it('runs the integration contract suite on PRs via ci.yml', () => {
61
+ const ci = readText('.github/workflows/ci.yml');
62
+ // Pull request trigger must be present so contract suite runs on PRs.
63
+ expect(ci).toMatch(/pull_request:/);
64
+ // The integration contract suite must be invoked. It ships as
65
+ // packages/cf-adapter/tests/integration (the parametrized harness
66
+ // that runs against every runtime) and is exercised by the
67
+ // workspace-wide coverage task. The gate step must explicitly
68
+ // reference the integration suite so it cannot be silently dropped.
69
+ expect(ci).toMatch(/integration/);
70
+ // Coverage must run on PRs (not only on main pushes).
71
+ const job = readWorkflowJob(ci, 'verify');
72
+ const runSteps = job.steps
73
+ .filter((s: { run?: unknown }) => typeof s.run === 'string')
74
+ .map((s: { run?: string }) => s.run ?? '');
75
+ const runsCoverage = runSteps.some((r) => r.includes('pnpm coverage'));
76
+ expect(runsCoverage).toBe(true);
77
+ });
78
+
79
+ it('has an explicit coverage-gate step that fails the build on regressions', () => {
80
+ const ci = readText('.github/workflows/ci.yml');
81
+ // The gate step name must make the failure mode obvious in the
82
+ // GitHub Actions UI.
83
+ expect(ci).toMatch(/coverage gate/i);
84
+ // The gate step must surface an explicit failure marker rather than
85
+ // relying on vitest's implicit threshold exit code alone.
86
+ expect(ci).toMatch(/coverage-thresholds/);
87
+ });
88
+ });
89
+
90
+ describe('mutation testing spot-check', () => {
91
+ it('exports a documented mutation-score threshold of ≥80%', () => {
92
+ expect(MUTATION_SCORE_THRESHOLD).toBeGreaterThanOrEqual(80);
93
+ });
94
+
95
+ it('ships a Stryker config that targets irc-core/protocol', () => {
96
+ const config = readStrykerConfig(repoPath('packages/irc-core/stryker.protocol.conf.json'));
97
+ expect(config.mutate).toEqual(
98
+ expect.arrayContaining([expect.stringMatching(/protocol\/[^/]+\.ts$/)]),
99
+ );
100
+ expect(config.thresholds?.high).toBeGreaterThanOrEqual(MUTATION_SCORE_THRESHOLD);
101
+ expect(config.thresholds?.break).toBe(MUTATION_SCORE_THRESHOLD);
102
+ // The config must disable Stryker's own coverage runner and reuse
103
+ // vitest's so the per-package coverage gate stays the source of
104
+ // truth for line/function coverage.
105
+ expect(config.coverageAnalysis).toBe('off');
106
+ expect(config.testRunner).toBe('vitest');
107
+ // Under pnpm's symlinked node_modules the Stryker child process
108
+ // cannot auto-discover the vitest plugin, so it must be listed
109
+ // explicitly.
110
+ expect(config.plugins).toEqual(['@stryker-mutator/vitest-runner']);
111
+ });
112
+
113
+ it('ships a Stryker config that targets irc-core/commands', () => {
114
+ const config = readStrykerConfig(repoPath('packages/irc-core/stryker.commands.conf.json'));
115
+ expect(config.mutate).toEqual(
116
+ expect.arrayContaining([expect.stringMatching(/commands\/[^/]+\.ts$/)]),
117
+ );
118
+ expect(config.thresholds?.high).toBeGreaterThanOrEqual(MUTATION_SCORE_THRESHOLD);
119
+ expect(config.thresholds?.break).toBe(MUTATION_SCORE_THRESHOLD);
120
+ expect(config.coverageAnalysis).toBe('off');
121
+ expect(config.testRunner).toBe('vitest');
122
+ expect(config.plugins).toEqual(['@stryker-mutator/vitest-runner']);
123
+ });
124
+
125
+ it('exposes a workspace-level mutation script that runs both spot-checks', () => {
126
+ const root = JSON.parse(readText('package.json')) as { scripts: Record<string, string> };
127
+ // Root scripts delegate to the irc-core package's stryker scripts.
128
+ expect(root.scripts['mutation:protocol']).toMatch(
129
+ /--filter @serverless-ircd\/irc-core mutation:protocol/,
130
+ );
131
+ expect(root.scripts['mutation:commands']).toMatch(
132
+ /--filter @serverless-ircd\/irc-core mutation:commands/,
133
+ );
134
+ expect(root.scripts.mutation).toMatch(/mutation:protocol/);
135
+ expect(root.scripts.mutation).toMatch(/mutation:commands/);
136
+
137
+ // The irc-core package must actually invoke stryker.
138
+ const ircCore = JSON.parse(readText('packages/irc-core/package.json')) as {
139
+ scripts: Record<string, string>;
140
+ };
141
+ expect(ircCore.scripts['mutation:protocol']).toMatch(
142
+ /stryker run stryker\.protocol\.conf\.json/,
143
+ );
144
+ expect(ircCore.scripts['mutation:commands']).toMatch(
145
+ /stryker run stryker\.commands\.conf\.json/,
146
+ );
147
+ });
148
+
149
+ it('runs mutation spot-checks in CI on every PR', () => {
150
+ const ci = readText('.github/workflows/ci.yml');
151
+ expect(ci).toMatch(/mutation/i);
152
+ expect(ci).toMatch(/stryker/);
153
+ });
154
+ });
155
+
156
+ describe('validate.ts error paths', () => {
157
+ it('readCoverageThresholds throws when no thresholds block exists', () => {
158
+ const tmpPath = repoPath('tools/ci-hardening/tests/__no_thresholds__.txt');
159
+ expect(() => readCoverageThresholds(tmpPath)).toThrow(/no thresholds block|No coverage/i);
160
+ });
161
+
162
+ it('readCoverageThresholds throws when a threshold key is missing', () => {
163
+ const tmpPath = repoPath('tools/ci-hardening/tests/__partial_thresholds__.txt');
164
+ expect(() => readCoverageThresholds(tmpPath)).toThrow(/No lines threshold/i);
165
+ });
166
+
167
+ it('readStrykerConfig throws when mutate is missing', () => {
168
+ const tmpPath = repoPath('tools/ci-hardening/tests/__stryker_bad__.json');
169
+ expect(() => readStrykerConfig(tmpPath)).toThrow(/missing mutate/);
170
+ });
171
+
172
+ it('readWorkflowJob throws when the job does not exist', () => {
173
+ expect(() => readWorkflowJob('jobs:\n other: {}\n', 'verify')).toThrow(
174
+ /no job named "verify"/,
175
+ );
176
+ });
177
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"]
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist"]
10
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ all: false,
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/index.ts'],
12
+ reporter: ['text', 'html', 'json-summary'],
13
+ thresholds: {
14
+ lines: 100,
15
+ functions: 100,
16
+ branches: 100,
17
+ statements: 100,
18
+ },
19
+ },
20
+ },
21
+ });
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Seed SASL PLAIN accounts into the DynamoDB `Accounts` table.
4
+ *
5
+ * Usage:
6
+ * node --import tsx tools/seed-aws-accounts.ts \
7
+ * --table StagingAccounts \
8
+ * --endpoint http://localhost:8000 \
9
+ * --accounts alice:s3cret bob:password2
10
+ *
11
+ * Or read from a file (newline-delimited `username:password`):
12
+ * node --import tsx tools/seed-aws-accounts.ts \
13
+ * --table StagingAccounts \
14
+ * --file accounts.txt
15
+ *
16
+ * The script hashes each password with scrypt (never writes plaintext).
17
+ * Re-running with the same username overwrites the prior row.
18
+ */
19
+
20
+ import { parseArgs } from 'node:util';
21
+ import { readFileSync } from 'node:fs';
22
+ import {
23
+ createDynamoDocumentClient,
24
+ putAccountCredential,
25
+ } from '@serverless-ircd/aws-adapter';
26
+
27
+ const { values } = parseArgs({
28
+ options: {
29
+ table: { type: 'string' },
30
+ endpoint: { type: 'string', default: undefined },
31
+ region: { type: 'string', default: undefined },
32
+ accounts: { type: 'string', multiple: true, default: [] },
33
+ file: { type: 'string', default: undefined },
34
+ },
35
+ });
36
+
37
+ if (values.table === undefined) {
38
+ console.error('Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]');
39
+ process.exit(1);
40
+ }
41
+
42
+ const clientOptions: Record<string, string> = {};
43
+ if (values.endpoint !== undefined) clientOptions.endpoint = values.endpoint;
44
+ if (values.region !== undefined) clientOptions.region = values.region;
45
+
46
+ const docClient = createDynamoDocumentClient(clientOptions);
47
+
48
+ const pairs: Array<{ username: string; password: string }> = [];
49
+
50
+ for (const pair of values.accounts) {
51
+ const sep = pair.indexOf(':');
52
+ if (sep <= 0) {
53
+ console.error(`Skipping malformed entry (no colon): ${pair}`);
54
+ continue;
55
+ }
56
+ pairs.push({ username: pair.slice(0, sep), password: pair.slice(sep + 1) });
57
+ }
58
+
59
+ if (values.file !== undefined) {
60
+ const content = readFileSync(values.file, 'utf-8');
61
+ for (const line of content.split('\n')) {
62
+ const trimmed = line.trim();
63
+ if (trimmed.length === 0) continue;
64
+ const sep = trimmed.indexOf(':');
65
+ if (sep <= 0) continue;
66
+ pairs.push({ username: trimmed.slice(0, sep), password: trimmed.slice(sep + 1) });
67
+ }
68
+ }
69
+
70
+ if (pairs.length === 0) {
71
+ console.error('No accounts to seed. Use --accounts or --file.');
72
+ process.exit(1);
73
+ }
74
+
75
+ for (const { username, password } of pairs) {
76
+ const entry = await putAccountCredential(docClient, values.table, username, password);
77
+ console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
78
+ }
79
+
80
+ console.log(`Done: ${pairs.length} account(s) written to ${values.table}.`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -29,6 +29,7 @@
29
29
  import { type Socket, createServer } from 'node:net';
30
30
  import { type RawData, WebSocket } from 'ws';
31
31
  import { LineScanner } from './line-scanner.js';
32
+ import { ConsoleLogger, LogLevel, type Logger } from './logger.js';
32
33
 
33
34
  /** Options accepted by {@link startForwarder}. */
34
35
  export interface StartForwarderOptions {
@@ -41,6 +42,12 @@ export interface StartForwarderOptions {
41
42
  * `ws:` or `wss:` scheme; otherwise `startForwarder` throws synchronously.
42
43
  */
43
44
  targetUrl: string;
45
+ /**
46
+ * Minimum log severity to emit. `debug` traces every relayed line/frame;
47
+ * `info` (default) logs connection lifecycle; `warn`/`error` quiet the
48
+ * bridge to faults only. Mirrors the irc-core `LogLevel` ordering.
49
+ */
50
+ logLevel?: LogLevel;
44
51
  }
45
52
 
46
53
  /** A running forwarder. Call {@link Forwarder.close} to shut it down. */
@@ -87,6 +94,8 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
87
94
  const target = parseTargetUrl(opts.targetUrl);
88
95
  const listenHost = opts.listenHost ?? '127.0.0.1';
89
96
  const targetHref = target.href;
97
+ const logger: Logger = new ConsoleLogger(undefined, opts.logLevel ?? LogLevel.Info);
98
+ let nextConnId = 1;
90
99
 
91
100
  const bridges = new Map<Socket, () => void>();
92
101
  const sockets = new Set<Socket>();
@@ -94,7 +103,7 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
94
103
 
95
104
  const server = createServer((tcp) => {
96
105
  sockets.add(tcp);
97
- bridges.set(tcp, bridgeConnection(tcp, targetHref, bridges, sockets));
106
+ bridges.set(tcp, bridgeConnection(tcp, targetHref, bridges, sockets, logger, nextConnId++));
98
107
  });
99
108
 
100
109
  const close = (): Promise<void> => {
@@ -132,6 +141,9 @@ export function startForwarder(opts: StartForwarderOptions): Promise<Forwarder>
132
141
  });
133
142
  }
134
143
 
144
+ /** Why a bridge tore down. Surfaces in the `bridge.closed` log record. */
145
+ type CloseReason = 'shutdown' | 'tcp-close' | 'tcp-error' | 'ws-close' | 'ws-error';
146
+
135
147
  /**
136
148
  * Wires one TCP socket to one fresh WebSocket. Returns a teardown closure
137
149
  * that closes both ends idempotently; the caller registers it in `bridges`
@@ -142,12 +154,17 @@ function bridgeConnection(
142
154
  targetHref: string,
143
155
  bridges: Map<Socket, () => void>,
144
156
  sockets: Set<Socket>,
157
+ logger: Logger,
158
+ connId: number,
145
159
  ): () => void {
146
160
  const ws = new WebSocket(targetHref);
147
161
  const outgoing = new LineScanner();
148
162
  const pending: string[] = [];
149
163
  let wsReady = false;
150
164
  let bridgeClosed = false;
165
+ const remote = `${tcp.remoteAddress ?? '?'}:${tcp.remotePort ?? '?'}`;
166
+
167
+ logger.info('bridge.accepted', { conn: connId, remote });
151
168
 
152
169
  const sendLine = (line: string): void => {
153
170
  if (wsReady) ws.send(line);
@@ -156,28 +173,35 @@ function bridgeConnection(
156
173
 
157
174
  tcp.on('data', (chunk: Buffer) => {
158
175
  for (const line of outgoing.push(chunk)) {
159
- if (line.length > 0) sendLine(line);
176
+ if (line.length > 0) {
177
+ logger.debug('tcp.line', { conn: connId, line });
178
+ sendLine(line);
179
+ }
160
180
  }
161
181
  });
162
182
 
163
183
  ws.on('open', () => {
164
184
  wsReady = true;
185
+ const flushed = pending.length;
165
186
  for (const line of pending) ws.send(line);
166
187
  pending.length = 0;
188
+ logger.debug('ws.open', { conn: connId, flushed });
167
189
  });
168
190
 
169
191
  ws.on('message', (data: RawData) => {
170
192
  const text = toBuffer(data).toString('utf8');
193
+ logger.debug('ws.frame', { conn: connId, frame: text });
171
194
  for (const line of splitFrameLines(text)) {
172
195
  if (line.length > 0) tcp.write(`${line}\r\n`);
173
196
  }
174
197
  });
175
198
 
176
- const teardown = (): void => {
199
+ const teardown = (reason: CloseReason): void => {
177
200
  if (bridgeClosed) return;
178
201
  bridgeClosed = true;
179
202
  bridges.delete(tcp);
180
203
  sockets.delete(tcp);
204
+ logger.info('bridge.closed', { conn: connId, reason });
181
205
  // ws.close() does not throw: on a CONNECTING socket it aborts the
182
206
  // handshake and emits an 'error' event, which our handler suppresses
183
207
  // via the `bridgeClosed` guard set above.
@@ -185,25 +209,25 @@ function bridgeConnection(
185
209
  tcp.destroy();
186
210
  };
187
211
 
188
- tcp.on('close', teardown);
212
+ tcp.on('close', () => teardown('tcp-close'));
189
213
  tcp.on('error', (err) => {
190
214
  // Errors that arrive after we already tore this bridge down (e.g. an
191
215
  // RST echoing our own `destroy()`) are expected — don't log them.
192
216
  if (bridgeClosed) return;
193
- logError('forwarder tcp socket error', err);
194
- teardown();
217
+ logger.error('forwarder tcp socket error', { conn: connId, err });
218
+ teardown('tcp-error');
195
219
  });
196
- ws.on('close', teardown);
220
+ ws.on('close', () => teardown('ws-close'));
197
221
  ws.on('error', (err) => {
198
222
  // Same suppression as above; also covers the benign "closed before the
199
223
  // connection was established" event ws emits when we close() a socket
200
224
  // that was still CONNECTING at shutdown time.
201
225
  if (bridgeClosed) return;
202
- logError('forwarder upstream ws error', err);
203
- teardown();
226
+ logger.error('forwarder upstream ws error', { conn: connId, err });
227
+ teardown('ws-error');
204
228
  });
205
229
 
206
- return teardown;
230
+ return () => teardown('shutdown');
207
231
  }
208
232
 
209
233
  /**
@@ -229,16 +253,3 @@ export function toBuffer(data: RawData): Buffer {
229
253
  if (data instanceof ArrayBuffer) return Buffer.from(data);
230
254
  return Buffer.from(data as Uint8Array);
231
255
  }
232
-
233
- function logError(msg: string, err: unknown): void {
234
- const payload = {
235
- ts: new Date().toISOString(),
236
- level: 'error',
237
- msg,
238
- err:
239
- err instanceof Error
240
- ? { name: err.name, message: err.message, stack: err.stack }
241
- : String(err),
242
- };
243
- console.error(JSON.stringify(payload));
244
- }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Self-contained structured logger for the TCP↔WS forwarder.
3
+ *
4
+ * The forwarder is deliberately dependency-free (no `@serverless-ircd/*`
5
+ * import) so it can ship as a standalone tool, but it mirrors the irc-core
6
+ * logging conventions so output is consistent across the monorepo:
7
+ *
8
+ * - numeric {@link LogLevel} enum with `debug < info < warn < error`;
9
+ * - one JSON line per record (`{ ts, level, msg, ...fields }`);
10
+ * - `minLevel` filtering;
11
+ * - injectable sinks (so tests never touch the real `console`); and
12
+ * - automatic `Error` serialization — any field value that is an `Error`
13
+ * becomes `{ name, message, stack }`, matching the legacy `logError`
14
+ * output the existing tests assert on.
15
+ */
16
+
17
+ /** Severity. Ordering matters: a logger set to `Info` suppresses `Debug`. */
18
+ export enum LogLevel {
19
+ Debug = 10,
20
+ Info = 20,
21
+ Warn = 30,
22
+ Error = 40,
23
+ }
24
+
25
+ /** String label written as the `level` field of each record. */
26
+ export function logLevelLabel(level: LogLevel): LogLevelLabel {
27
+ switch (level) {
28
+ case LogLevel.Debug:
29
+ return 'debug';
30
+ case LogLevel.Info:
31
+ return 'info';
32
+ case LogLevel.Warn:
33
+ return 'warn';
34
+ case LogLevel.Error:
35
+ return 'error';
36
+ }
37
+ }
38
+
39
+ /** A parsed, case-insensitive log-level label. */
40
+ export type LogLevelLabel = 'debug' | 'info' | 'warn' | 'error';
41
+
42
+ const LABEL_TO_LEVEL: Record<LogLevelLabel, LogLevel> = {
43
+ debug: LogLevel.Debug,
44
+ info: LogLevel.Info,
45
+ warn: LogLevel.Warn,
46
+ error: LogLevel.Error,
47
+ };
48
+
49
+ /**
50
+ * Parses a log-level label (case-insensitive, whitespace-trimmed) into a
51
+ * {@link LogLevel}. Throws on anything that is not one of the four canonical
52
+ * labels so a typo at startup fails loudly instead of silently degrading to a
53
+ * default.
54
+ */
55
+ export function parseLogLevel(raw: string): LogLevel {
56
+ const key = raw.trim().toLowerCase();
57
+ if (key in LABEL_TO_LEVEL) {
58
+ return LABEL_TO_LEVEL[key as LogLevelLabel];
59
+ }
60
+ throw new Error(`unknown log level "${raw}" (expected one of: debug, info, warn, error)`);
61
+ }
62
+
63
+ /** Per-level output sinks. Defaults to the global `console`. */
64
+ export interface LoggerSinks {
65
+ debug(line: string): void;
66
+ info(line: string): void;
67
+ warn(line: string): void;
68
+ error(line: string): void;
69
+ }
70
+
71
+ /** Minimal structured logger port the forwarder speaks. */
72
+ export interface Logger {
73
+ debug(msg: string, fields?: Record<string, unknown>): void;
74
+ info(msg: string, fields?: Record<string, unknown>): void;
75
+ warn(msg: string, fields?: Record<string, unknown>): void;
76
+ error(msg: string, fields?: Record<string, unknown>): void;
77
+ }
78
+
79
+ /**
80
+ * Structured logger that writes one JSON line per record.
81
+ *
82
+ * Output shape: `{ ts, level, msg, ...fields }`. Any field value that is an
83
+ * `Error` is serialized to `{ name, message, stack }`; all other values pass
84
+ * through unchanged (so non-Error errors surface as their string form).
85
+ */
86
+ export class ConsoleLogger implements Logger {
87
+ private readonly minLevel: LogLevel;
88
+ private readonly sinks: LoggerSinks;
89
+
90
+ constructor(sinks?: LoggerSinks, minLevel: LogLevel = LogLevel.Debug) {
91
+ this.sinks = sinks ?? globalConsoleSinks();
92
+ this.minLevel = minLevel;
93
+ }
94
+
95
+ debug(msg: string, fields?: Record<string, unknown>): void {
96
+ this.write(LogLevel.Debug, msg, fields);
97
+ }
98
+ info(msg: string, fields?: Record<string, unknown>): void {
99
+ this.write(LogLevel.Info, msg, fields);
100
+ }
101
+ warn(msg: string, fields?: Record<string, unknown>): void {
102
+ this.write(LogLevel.Warn, msg, fields);
103
+ }
104
+ error(msg: string, fields?: Record<string, unknown>): void {
105
+ this.write(LogLevel.Error, msg, fields);
106
+ }
107
+
108
+ private write(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
109
+ if (level < this.minLevel) return;
110
+ const payload: Record<string, unknown> = {
111
+ ts: new Date().toISOString(),
112
+ level: logLevelLabel(level),
113
+ msg,
114
+ };
115
+ if (fields !== undefined) {
116
+ for (const [key, value] of Object.entries(fields)) {
117
+ payload[key] = value instanceof Error ? serializeError(value) : value;
118
+ }
119
+ }
120
+ const line = JSON.stringify(payload);
121
+ switch (level) {
122
+ case LogLevel.Debug:
123
+ this.sinks.debug(line);
124
+ return;
125
+ case LogLevel.Info:
126
+ this.sinks.info(line);
127
+ return;
128
+ case LogLevel.Warn:
129
+ this.sinks.warn(line);
130
+ return;
131
+ case LogLevel.Error:
132
+ this.sinks.error(line);
133
+ return;
134
+ }
135
+ }
136
+ }
137
+
138
+ /** Returns the global `console` mapped onto {@link LoggerSinks}. */
139
+ function globalConsoleSinks(): LoggerSinks {
140
+ const c = (globalThis as { console?: Partial<Console> }).console;
141
+ if (c === undefined) {
142
+ const drop = (): void => {};
143
+ return { debug: drop, info: drop, warn: drop, error: drop };
144
+ }
145
+ return {
146
+ debug: (l) => c.debug?.(l),
147
+ info: (l) => c.info?.(l),
148
+ warn: (l) => c.warn?.(l),
149
+ error: (l) => c.error?.(l),
150
+ };
151
+ }
152
+
153
+ function serializeError(err: Error): { name: string; message: string; stack: string | undefined } {
154
+ return { name: err.name, message: err.message, stack: err.stack };
155
+ }