serverless-ircd 0.1.0 → 0.2.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 (198) 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/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@serverless-ircd/ci-hardening",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.build.json",
12
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "coverage": "vitest run --coverage",
16
+ "start": "node --enable-source-maps ./dist/main.js",
17
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
18
+ },
19
+ "dependencies": {
20
+ "js-yaml": "^4.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/js-yaml": "^4.0.9",
24
+ "@types/node": "^26.1.1",
25
+ "@vitest/coverage-v8": "^4.1.0",
26
+ "rimraf": "^6.0.0",
27
+ "typescript": "^5.6.0",
28
+ "vitest": "^4.1.0"
29
+ }
30
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ MUTATION_SCORE_THRESHOLD,
3
+ readCoverageThresholds,
4
+ readStrykerConfig,
5
+ readWorkflowJob,
6
+ } from './validate.js';
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { load } from 'js-yaml';
3
+
4
+ /**
5
+ * Minimum mutation score (in percent) required for the Stryker
6
+ * spot-checks on `irc-core/protocol` and `irc-core/commands`.
7
+ *
8
+ * Exported so the test-suite and any future dashboards can
9
+ * import the canonical value instead of re-hard-coding it.
10
+ */
11
+ export const MUTATION_SCORE_THRESHOLD = 80;
12
+
13
+ export interface CoverageThresholds {
14
+ lines: number;
15
+ functions: number;
16
+ branches: number;
17
+ statements: number;
18
+ }
19
+
20
+ interface StrykerThresholds {
21
+ high?: number;
22
+ low?: number;
23
+ break?: number | null;
24
+ }
25
+
26
+ interface StrykerConfig {
27
+ mutate?: unknown;
28
+ thresholds?: StrykerThresholds;
29
+ coverageAnalysis?: string;
30
+ testRunner?: string;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ interface WorkflowJob {
35
+ steps: Array<{ name?: string; run?: string; [key: string]: unknown }>;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ interface ParsedWorkflow {
40
+ jobs?: Record<string, WorkflowJob>;
41
+ }
42
+
43
+ /**
44
+ * Extract the `coverage.thresholds` block from a vitest config file.
45
+ *
46
+ * Vitest configs are plain TypeScript modules; rather than pulling in
47
+ * the full TS toolchain to evaluate them, we parse the source as text
48
+ * and pull out the `thresholds: { ... }` object. This deliberately
49
+ * fails loud (returns NaN-bearing values) if the config shape drifts,
50
+ * so the test-suite notices immediately.
51
+ *
52
+ * @param configPath absolute path to a vitest.config.ts file.
53
+ */
54
+ export function readCoverageThresholds(configPath: string): CoverageThresholds {
55
+ const src = readFileSync(configPath, 'utf8');
56
+ const match = src.match(/thresholds:\s*\{([^}]*)\}/);
57
+ if (match === null || match[1] === undefined) {
58
+ throw new Error(`No coverage.thresholds block found in ${configPath}`);
59
+ }
60
+ const block = match[1];
61
+ const grab = (key: string): number => {
62
+ const m = block.match(new RegExp(`${key}:\\s*(\\d+)`));
63
+ if (m === null || m[1] === undefined) {
64
+ throw new Error(`No ${key} threshold found in ${configPath}`);
65
+ }
66
+ return Number.parseInt(m[1], 10);
67
+ };
68
+ return {
69
+ lines: grab('lines'),
70
+ functions: grab('functions'),
71
+ branches: grab('branches'),
72
+ statements: grab('statements'),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Load and validate a Stryker JSON config file.
78
+ *
79
+ * @param configPath absolute path to a `stryker.*.conf.json` file.
80
+ */
81
+ export function readStrykerConfig(configPath: string): StrykerConfig {
82
+ const raw = readFileSync(configPath, 'utf8');
83
+ const parsed = JSON.parse(raw) as StrykerConfig;
84
+ if (!Array.isArray(parsed.mutate)) {
85
+ throw new Error(`Stryker config at ${configPath} is missing mutate[]`);
86
+ }
87
+ return parsed;
88
+ }
89
+
90
+ /**
91
+ * Parse a GitHub Actions YAML file and return the named job.
92
+ *
93
+ * @param yamlText raw YAML source of a workflow file.
94
+ * @param jobName key under `jobs:`.
95
+ */
96
+ export function readWorkflowJob(yamlText: string, jobName: string): WorkflowJob {
97
+ const parsed = load(yamlText) as ParsedWorkflow;
98
+ const job = parsed.jobs?.[jobName];
99
+ if (job === undefined) {
100
+ throw new Error(`Workflow has no job named "${jobName}"`);
101
+ }
102
+ return job;
103
+ }
@@ -0,0 +1,11 @@
1
+ // Fixture: vitest config with no thresholds block at all.
2
+ import { defineConfig } from 'vitest/config';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ },
10
+ },
11
+ });
@@ -0,0 +1,16 @@
1
+ // Fixture: vitest config with only some threshold keys present.
2
+ import { defineConfig } from 'vitest/config';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ thresholds: {
10
+ functions: 100,
11
+ branches: 100,
12
+ statements: 100,
13
+ },
14
+ },
15
+ },
16
+ });
@@ -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
+ });
@@ -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
- }