serverless-ircd 0.10.0 → 0.11.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Deploy-time hostname placeholder rejection.
3
+ *
4
+ * The repo's deploy configs ship placeholder defaults
5
+ * (`SERVER_NAME = "irc.your-domain.invalid"` in both wrangler.toml
6
+ * files; the TCP workflow used to hardcode
7
+ * `TF_VAR_hostname: irc.example.com` for the Spectrum apply). A
8
+ * deploy that still carries one of those placeholders advertises a
9
+ * hostname the operator does not own — the wss Worker emits it in
10
+ * numerics (001/005) and the Spectrum cert/handshake targets it.
11
+ *
12
+ * The guard (`tools/ci-hardening` `hostname-guard`, driven by
13
+ * `runHostnameGuard`) refuses those deploys: the deploy-cf and
14
+ * deploy-cf-tcp workflows fail loudly unless the `hostname` input is
15
+ * a real hostname, and the guarded `wrangler deploy` scripts refuse
16
+ * when the deploy-time `SERVER_NAME` is still a placeholder. These
17
+ * assertions run as part of the CI-hardening suite on every PR, the
18
+ * same way the OIDC-only deploy contract does.
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import { resolve } from 'node:path';
23
+ import { load } from 'js-yaml';
24
+ import { describe, expect, it } from 'vitest';
25
+ import {
26
+ PLACEHOLDER_HOSTNAMES,
27
+ checkDeployHostname,
28
+ isPlaceholderHostname,
29
+ readServerNameFromWranglerToml,
30
+ runHostnameGuard,
31
+ } from '../src/deploy-hostname.js';
32
+
33
+ interface WorkflowInput {
34
+ required?: boolean;
35
+ type?: string;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ interface WorkflowStep {
40
+ name?: string;
41
+ run?: string;
42
+ env?: Record<string, unknown>;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ interface DispatchWorkflow {
47
+ on?: { workflow_dispatch?: { inputs?: Record<string, WorkflowInput> } };
48
+ jobs?: Record<string, { steps?: WorkflowStep[] }>;
49
+ }
50
+
51
+ function loadWorkflow(rel: string): DispatchWorkflow {
52
+ return load(readText(rel)) as DispatchWorkflow;
53
+ }
54
+
55
+ function stepsOf(workflow: DispatchWorkflow, jobName: string): WorkflowStep[] {
56
+ const steps = workflow.jobs?.[jobName]?.steps;
57
+ expect(steps).toBeDefined();
58
+ return steps ?? [];
59
+ }
60
+
61
+ function stepIndex(steps: WorkflowStep[], predicate: (step: WorkflowStep) => boolean): number {
62
+ const index = steps.findIndex(predicate);
63
+ expect(index).toBeGreaterThanOrEqual(0);
64
+ return index;
65
+ }
66
+
67
+ function isHostnameGuardStep(step: WorkflowStep): boolean {
68
+ return typeof step.run === 'string' && step.run.includes('hostname-guard');
69
+ }
70
+
71
+ const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
72
+
73
+ function repoPath(rel: string): string {
74
+ return resolve(REPO_ROOT, rel);
75
+ }
76
+
77
+ function readText(rel: string): string {
78
+ return readFileSync(repoPath(rel), 'utf8');
79
+ }
80
+
81
+ function fixturePath(name: string): string {
82
+ return resolve(import.meta.dirname, name);
83
+ }
84
+
85
+ describe('isPlaceholderHostname', () => {
86
+ it('recognizes irc.example.com as a placeholder', () => {
87
+ expect(isPlaceholderHostname('irc.example.com')).toBe(true);
88
+ });
89
+
90
+ it('recognizes irc.your-domain.invalid as a placeholder', () => {
91
+ expect(isPlaceholderHostname('irc.your-domain.invalid')).toBe(true);
92
+ });
93
+
94
+ it('does not flag a real deploy hostname', () => {
95
+ expect(isPlaceholderHostname('irc.example.net')).toBe(false);
96
+ });
97
+
98
+ it('matches case-insensitively after trimming', () => {
99
+ expect(isPlaceholderHostname(' IRC.Example.COM ')).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('checkDeployHostname', () => {
104
+ it('rejects a missing hostname', () => {
105
+ const check = checkDeployHostname(undefined);
106
+ expect(check.ok).toBe(false);
107
+ });
108
+
109
+ it('rejects an empty hostname', () => {
110
+ const check = checkDeployHostname('');
111
+ expect(check.ok).toBe(false);
112
+ });
113
+
114
+ it('rejects a whitespace-only hostname', () => {
115
+ const check = checkDeployHostname(' ');
116
+ expect(check.ok).toBe(false);
117
+ });
118
+
119
+ it('explains that a missing hostname is required', () => {
120
+ const check = checkDeployHostname(undefined);
121
+ expect(check.ok === false && check.error).toMatch(/required/);
122
+ });
123
+
124
+ it('rejects irc.example.com naming the placeholder', () => {
125
+ const check = checkDeployHostname('irc.example.com');
126
+ expect(check.ok === false && check.error).toContain('irc.example.com');
127
+ });
128
+
129
+ it('rejects irc.your-domain.invalid naming the placeholder', () => {
130
+ const check = checkDeployHostname('irc.your-domain.invalid');
131
+ expect(check.ok === false && check.error).toContain('irc.your-domain.invalid');
132
+ });
133
+
134
+ it('rejects a case-variant placeholder', () => {
135
+ const check = checkDeployHostname('IRC.Your-Domain.Invalid');
136
+ expect(check.ok).toBe(false);
137
+ });
138
+
139
+ it('accepts a real hostname, trimmed and lowercased', () => {
140
+ expect(checkDeployHostname(' IRC.Example.Org ')).toEqual({
141
+ ok: true,
142
+ hostname: 'irc.example.org',
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('readServerNameFromWranglerToml', () => {
148
+ it('extracts SERVER_NAME from a [vars] block', () => {
149
+ const toml = '[vars]\nSERVER_NAME = "irc.your-domain.invalid"\n';
150
+ expect(readServerNameFromWranglerToml(toml)).toBe('irc.your-domain.invalid');
151
+ });
152
+
153
+ it('accepts single-quoted values', () => {
154
+ const toml = "[vars]\nSERVER_NAME = 'irc.example.net'\n";
155
+ expect(readServerNameFromWranglerToml(toml)).toBe('irc.example.net');
156
+ });
157
+
158
+ it('returns undefined when SERVER_NAME is absent', () => {
159
+ expect(readServerNameFromWranglerToml('[vars]\nNETWORK_NAME = "x"\n')).toBeUndefined();
160
+ });
161
+
162
+ it('ignores commented-out assignments', () => {
163
+ const toml = '[vars]\n# SERVER_NAME = "irc.example.com"\n';
164
+ expect(readServerNameFromWranglerToml(toml)).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ describe('runHostnameGuard', () => {
169
+ it('accepts an explicit real hostname (exit 0)', () => {
170
+ const result = runHostnameGuard(['irc.example.net'], {});
171
+ expect(result.exitCode).toBe(0);
172
+ });
173
+
174
+ it('rejects an explicit placeholder hostname with a GitHub error annotation (exit 1)', () => {
175
+ const result = runHostnameGuard(['irc.example.com'], {});
176
+ expect(result.exitCode).toBe(1);
177
+ expect(result.message).toMatch(/^::error::/);
178
+ expect(result.message).toContain('irc.example.com');
179
+ });
180
+
181
+ it('rejects a missing argument with usage (exit 2)', () => {
182
+ const result = runHostnameGuard([], {});
183
+ expect(result.exitCode).toBe(2);
184
+ expect(result.message).toMatch(/usage/);
185
+ });
186
+
187
+ it('rejects multiple arguments with usage (exit 2)', () => {
188
+ const result = runHostnameGuard(['irc.example.net', 'extra'], {});
189
+ expect(result.exitCode).toBe(2);
190
+ });
191
+
192
+ it('rejects --from-config without a path with usage (exit 2)', () => {
193
+ const result = runHostnameGuard(['--from-config'], {});
194
+ expect(result.exitCode).toBe(2);
195
+ expect(result.message).toMatch(/usage/);
196
+ });
197
+
198
+ it('rejects --from-config with an empty path with usage (exit 2)', () => {
199
+ const result = runHostnameGuard(['--from-config', ''], {});
200
+ expect(result.exitCode).toBe(2);
201
+ });
202
+
203
+ it('rejects the shipped placeholder read from a wrangler config (exit 1)', () => {
204
+ const result = runHostnameGuard(
205
+ ['--from-config', fixturePath('__wrangler_placeholder__.toml')],
206
+ {},
207
+ );
208
+ expect(result.exitCode).toBe(1);
209
+ expect(result.message).toContain('irc.your-domain.invalid');
210
+ });
211
+
212
+ it('accepts a real SERVER_NAME read from a wrangler config (exit 0)', () => {
213
+ const result = runHostnameGuard(['--from-config', fixturePath('__wrangler_real__.toml')], {});
214
+ expect(result.exitCode).toBe(0);
215
+ });
216
+
217
+ it('prefers the SERVER_NAME env override over the config file', () => {
218
+ const result = runHostnameGuard(
219
+ ['--from-config', fixturePath('__wrangler_placeholder__.toml')],
220
+ { SERVER_NAME: 'irc.example.net' },
221
+ );
222
+ expect(result.exitCode).toBe(0);
223
+ });
224
+
225
+ it('falls back to the config when the SERVER_NAME env override is blank', () => {
226
+ const result = runHostnameGuard(
227
+ ['--from-config', fixturePath('__wrangler_placeholder__.toml')],
228
+ { SERVER_NAME: '' },
229
+ );
230
+ expect(result.exitCode).toBe(1);
231
+ });
232
+
233
+ it('fails clearly when the config sets no SERVER_NAME and no override exists', () => {
234
+ const result = runHostnameGuard(['--from-config', fixturePath('__wrangler_missing__.toml')], {
235
+ SERVER_NAME: undefined,
236
+ });
237
+ expect(result.exitCode).toBe(1);
238
+ expect(result.message).toContain('SERVER_NAME is not set');
239
+ });
240
+
241
+ it('crashes loudly when the config path does not exist', () => {
242
+ expect(() =>
243
+ runHostnameGuard(['--from-config', fixturePath('__no_such_file__.toml')], {}),
244
+ ).toThrow();
245
+ });
246
+ });
247
+
248
+ describe('shipped wrangler defaults stay recognized placeholders', () => {
249
+ it('cf-worker SERVER_NAME default is a recognized placeholder', () => {
250
+ const serverName = readServerNameFromWranglerToml(readText('apps/cf-worker/wrangler.toml'));
251
+ expect(serverName !== undefined && PLACEHOLDER_HOSTNAMES.has(serverName)).toBe(true);
252
+ });
253
+
254
+ it('cf-tcp-container SERVER_NAME default is a recognized placeholder', () => {
255
+ const serverName = readServerNameFromWranglerToml(
256
+ readText('apps/cf-tcp-container/wrangler.toml'),
257
+ );
258
+ expect(serverName !== undefined && PLACEHOLDER_HOSTNAMES.has(serverName)).toBe(true);
259
+ });
260
+ });
261
+
262
+ describe('deploy-cf-tcp.yml: hostname is a required, guarded deploy input', () => {
263
+ it('declares the hostname workflow_dispatch input as required', () => {
264
+ const hostname = loadWorkflow('.github/workflows/deploy-cf-tcp.yml').on?.workflow_dispatch
265
+ ?.inputs?.hostname;
266
+ expect(hostname?.required).toBe(true);
267
+ });
268
+
269
+ it('sources TF_VAR_hostname from the input instead of a hardcoded placeholder', () => {
270
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf-tcp.yml'), 'deploy');
271
+ const terraform = steps[stepIndex(steps, (s) => s.run?.includes('terraform apply') === true)];
272
+ expect(terraform?.env?.TF_VAR_hostname).toBe('${{ inputs.hostname }}');
273
+ });
274
+
275
+ it('guards against placeholder hostnames before any deploy or terraform step', () => {
276
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf-tcp.yml'), 'deploy');
277
+ const guard = stepIndex(steps, isHostnameGuardStep);
278
+ const deploy = stepIndex(steps, (s) => s.name === 'Deploy container');
279
+ const terraform = stepIndex(steps, (s) => s.run?.includes('terraform apply') === true);
280
+ expect(guard).toBeLessThan(deploy);
281
+ expect(guard).toBeLessThan(terraform);
282
+ });
283
+
284
+ it('validates the hostname input through the guard step', () => {
285
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf-tcp.yml'), 'deploy');
286
+ const guard = steps[stepIndex(steps, isHostnameGuardStep)];
287
+ expect(guard?.env?.DEPLOY_HOSTNAME).toBe('${{ inputs.hostname }}');
288
+ expect(guard?.run).toContain('"$DEPLOY_HOSTNAME"');
289
+ });
290
+
291
+ it('overrides the container SERVER_NAME with the input hostname at deploy time', () => {
292
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf-tcp.yml'), 'deploy');
293
+ const deploy = steps[stepIndex(steps, (s) => s.name === 'Deploy container')];
294
+ expect(deploy?.env?.SERVER_NAME).toBe('${{ inputs.hostname }}');
295
+ expect(deploy?.run).toContain('--var');
296
+ expect(deploy?.run).toContain('SERVER_NAME:');
297
+ });
298
+ });
299
+
300
+ describe('deploy-cf.yml: hostname is a required, guarded deploy input', () => {
301
+ it('declares the hostname workflow_dispatch input as required', () => {
302
+ const hostname = loadWorkflow('.github/workflows/deploy-cf.yml').on?.workflow_dispatch?.inputs
303
+ ?.hostname;
304
+ expect(hostname?.required).toBe(true);
305
+ });
306
+
307
+ it('guards against placeholder hostnames before the deploy step', () => {
308
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf.yml'), 'deploy');
309
+ const guard = stepIndex(steps, isHostnameGuardStep);
310
+ const deploy = stepIndex(steps, (s) => s.name === 'Deploy Worker');
311
+ expect(guard).toBeLessThan(deploy);
312
+ });
313
+
314
+ it('overrides the worker SERVER_NAME with the input hostname at deploy time', () => {
315
+ const steps = stepsOf(loadWorkflow('.github/workflows/deploy-cf.yml'), 'deploy');
316
+ const deploy = steps[stepIndex(steps, (s) => s.name === 'Deploy Worker')];
317
+ // The guarded cf-deploy wrapper resolves SERVER_NAME from the step env
318
+ // (the workflow input) and injects it into wrangler itself — the run
319
+ // step only needs `pnpm run deploy`.
320
+ expect(deploy?.env?.SERVER_NAME).toBe('${{ inputs.hostname }}');
321
+ expect(deploy?.run).toContain('pnpm run deploy');
322
+ });
323
+ });
324
+
325
+ describe('wrangler deploy is guarded by the hostname check', () => {
326
+ it('cf-worker deploy script runs the guarded cf-deploy wrapper', () => {
327
+ const pkg = JSON.parse(readText('apps/cf-worker/package.json')) as {
328
+ scripts?: Record<string, string>;
329
+ };
330
+ const deploy = pkg.scripts?.deploy ?? '';
331
+ expect(deploy).toContain('ci-hardening/dist/cf-deploy-cli.js');
332
+ expect(deploy).toContain('wrangler.toml');
333
+ });
334
+
335
+ it('cf-tcp-container deploy script runs the guarded cf-deploy wrapper', () => {
336
+ const pkg = JSON.parse(readText('apps/cf-tcp-container/package.json')) as {
337
+ scripts?: Record<string, string>;
338
+ };
339
+ const deploy = pkg.scripts?.deploy ?? '';
340
+ expect(deploy).toContain('ci-hardening/dist/cf-deploy-cli.js');
341
+ expect(deploy).toContain('wrangler.toml');
342
+ });
343
+
344
+ it('root deploy:cf builds the workspace first (the guard ships as built dist)', () => {
345
+ const pkg = JSON.parse(readText('package.json')) as { scripts?: Record<string, string> };
346
+ expect(pkg.scripts?.['deploy:cf']).toContain('pnpm build');
347
+ });
348
+ });
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Env-var / secret documentation drift guard.
3
+ *
4
+ * The wrangler.toml `consumed-env-vars` comment block is the operator
5
+ * contract for what the Cloudflare Worker consumes and which vars are
6
+ * secrets. Two test layers:
7
+ *
8
+ * 1. A contract test against the REAL repo files — the loader
9
+ * source and the shipped wrangler.toml — so any env var added to
10
+ * `CfConfigEnv` without a matching doc entry (or vice versa)
11
+ * fails CI with a named error.
12
+ * 2. Unit tests over synthetic fixtures pinning each pure helper
13
+ * and each drift finding (missing / stale / misclassified /
14
+ * leaked-secret).
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import { describe, expect, it } from 'vitest';
20
+ import {
21
+ EXTRA_DOCUMENTED_ENV_VARS,
22
+ REQUIRED_SECRET_ENV_VARS,
23
+ checkEnvVarDrift,
24
+ extractLoaderEnvVars,
25
+ extractVarsTableKeys,
26
+ parseConsumedEnvBlock,
27
+ } from '../src/env-var-drift.js';
28
+
29
+ const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
30
+
31
+ function repoPath(rel: string): string {
32
+ return resolve(REPO_ROOT, rel);
33
+ }
34
+
35
+ function readText(rel: string): string {
36
+ return readFileSync(repoPath(rel), 'utf8');
37
+ }
38
+
39
+ const CONFIG_LOADER = 'packages/cf-adapter/src/config-loader.ts';
40
+ const WRANGLER_TOML = 'apps/cf-worker/wrangler.toml';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Contract — the real repo files must not drift.
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('wrangler.toml consumed-env-vars block (drift guard)', () => {
47
+ it('documents every env var the cf-adapter config loader consumes', () => {
48
+ const result = checkEnvVarDrift(readText(CONFIG_LOADER), readText(WRANGLER_TOML));
49
+ if (!result.ok) {
50
+ throw new Error(`env-var documentation drift:\n - ${result.errors.join('\n - ')}`);
51
+ }
52
+ expect(result.documentedCount).toBeGreaterThan(0);
53
+ });
54
+
55
+ it('ships no credential material as a plaintext [vars] key', () => {
56
+ const varsKeys = extractVarsTableKeys(readText(WRANGLER_TOML));
57
+ for (const name of REQUIRED_SECRET_ENV_VARS) {
58
+ expect(varsKeys.has(name)).toBe(false);
59
+ }
60
+ });
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // extractLoaderEnvVars — the CfConfigEnv member scan.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('extractLoaderEnvVars', () => {
68
+ it('extracts and sorts the interface members (required and optional)', () => {
69
+ const source = [
70
+ 'const DEFAULT_PER_IP_RATE_MAX = 5;',
71
+ '',
72
+ 'export interface CfConfigEnv {',
73
+ ' ZEBRA?: string;',
74
+ ' ALPHA: string;',
75
+ ' /**',
76
+ ' * OPER_PASSWORD prose mentioning names does not match.',
77
+ ' */',
78
+ ' MOTD_LINES?: string;',
79
+ '}',
80
+ '',
81
+ 'export interface Unrelated {',
82
+ ' OTHER?: string;',
83
+ '}',
84
+ ].join('\n');
85
+ expect(extractLoaderEnvVars(source)).toEqual(['ALPHA', 'MOTD_LINES', 'ZEBRA']);
86
+ });
87
+
88
+ it('de-duplicates repeated member names', () => {
89
+ const source =
90
+ 'export interface CfConfigEnv {\n MAX_CLIENTS?: string;\n MAX_CLIENTS?: string;\n}';
91
+ expect(extractLoaderEnvVars(source)).toEqual(['MAX_CLIENTS']);
92
+ });
93
+
94
+ it('returns an empty list when the interface is absent', () => {
95
+ expect(extractLoaderEnvVars('export const nothing = 1;')).toEqual([]);
96
+ });
97
+
98
+ it('scans to end-of-file when the interface has no closing brace', () => {
99
+ const source = 'export interface CfConfigEnv {\n MAX_CLIENTS?: string;';
100
+ expect(extractLoaderEnvVars(source)).toEqual(['MAX_CLIENTS']);
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // parseConsumedEnvBlock — the wrangler.toml block parser.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('parseConsumedEnvBlock', () => {
109
+ it('parses [vars] and [secret] entries and ignores prose lines', () => {
110
+ const toml = [
111
+ '## lead-in prose',
112
+ '## BEGIN consumed-env-vars',
113
+ '## [vars] SERVER_NAME — required hostname',
114
+ '## prose line without a classification marker',
115
+ '## [secret] SERVER_PASSWORD — PASS gate',
116
+ '## END consumed-env-vars',
117
+ '## [vars] AFTER_BLOCK — must not be collected',
118
+ ].join('\n');
119
+ expect(parseConsumedEnvBlock(toml)).toEqual([
120
+ { name: 'SERVER_NAME', classification: 'vars' },
121
+ { name: 'SERVER_PASSWORD', classification: 'secret' },
122
+ ]);
123
+ });
124
+
125
+ it('returns an empty list when the block markers are absent', () => {
126
+ expect(parseConsumedEnvBlock('## [vars] SERVER_NAME — no block here')).toEqual([]);
127
+ });
128
+
129
+ it('scans to end-of-file when the END marker is missing', () => {
130
+ const toml = '## BEGIN consumed-env-vars\n## [secret] SASL_ACCOUNTS — seed list';
131
+ expect(parseConsumedEnvBlock(toml)).toEqual([
132
+ { name: 'SASL_ACCOUNTS', classification: 'secret' },
133
+ ]);
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // extractVarsTableKeys — the plaintext [vars] table scan.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe('extractVarsTableKeys', () => {
142
+ it('collects keys from [vars] only, skipping comments and other tables', () => {
143
+ const toml = [
144
+ '## comment header',
145
+ '[observability]',
146
+ 'enabled = true',
147
+ '',
148
+ '[vars]',
149
+ 'SERVER_NAME = "irc.example.net"',
150
+ '# SERVER_PASSWORD = "commented-out"',
151
+ 'NETWORK_NAME = "net"',
152
+ '',
153
+ '[assets]',
154
+ 'directory = "dist"',
155
+ ].join('\n');
156
+ expect(extractVarsTableKeys(toml)).toEqual(new Set(['SERVER_NAME', 'NETWORK_NAME']));
157
+ });
158
+
159
+ it('returns an empty set when there is no [vars] table', () => {
160
+ expect(extractVarsTableKeys('[observability]\nenabled = true')).toEqual(new Set());
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // checkEnvVarDrift — the combined findings.
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** Minimal in-sync fixture used as the base for each failure mutation. */
169
+ function inSyncFixture(): { loader: string; toml: string } {
170
+ const loader = [
171
+ 'export interface CfConfigEnv {',
172
+ ' SERVER_NAME?: string;',
173
+ ' OPER_PASSWORD?: string;',
174
+ ' SASL_ACCOUNTS?: string;',
175
+ '}',
176
+ ].join('\n');
177
+ const toml = [
178
+ '## BEGIN consumed-env-vars',
179
+ '## [vars] SERVER_NAME — required hostname',
180
+ '## [secret] OPER_PASSWORD — oper credential',
181
+ '## [secret] SASL_ACCOUNTS — SASL seed list',
182
+ '## END consumed-env-vars',
183
+ '',
184
+ '[vars]',
185
+ 'SERVER_NAME = "irc.example.net"',
186
+ ].join('\n');
187
+ return { loader, toml };
188
+ }
189
+
190
+ describe('checkEnvVarDrift', () => {
191
+ it('passes an in-sync loader/block pair', () => {
192
+ const { loader, toml } = inSyncFixture();
193
+ expect(checkEnvVarDrift(loader, toml)).toEqual({ ok: true, documentedCount: 3 });
194
+ });
195
+
196
+ it('flags a consumed var missing from the block', () => {
197
+ const { loader, toml } = inSyncFixture();
198
+ const grownLoader = loader.replace(
199
+ ' SASL_ACCOUNTS?: string;',
200
+ ' SASL_ACCOUNTS?: string;\n MAX_FRAMES_PER_WINDOW?: string;',
201
+ );
202
+ const result = checkEnvVarDrift(grownLoader, toml);
203
+ expect(result.ok).toBe(false);
204
+ if (result.ok) return;
205
+ expect(result.errors.length).toBe(1);
206
+ expect(result.errors[0]).toContain('MAX_FRAMES_PER_WINDOW');
207
+ expect(result.errors[0]).toContain('not documented');
208
+ });
209
+
210
+ it('flags a stale block entry the loader no longer consumes', () => {
211
+ const { loader, toml } = inSyncFixture();
212
+ const staleToml = toml.replace(
213
+ '## END consumed-env-vars',
214
+ '## [vars] RETIRED_KNOB — removed last release\n## END consumed-env-vars',
215
+ );
216
+ const result = checkEnvVarDrift(loader, staleToml);
217
+ expect(result.ok).toBe(false);
218
+ if (result.ok) return;
219
+ expect(result.errors.length).toBe(1);
220
+ expect(result.errors[0]).toContain('RETIRED_KNOB');
221
+ expect(result.errors[0]).toContain('no longer consumed');
222
+ });
223
+
224
+ it('permits documented vars consumed outside the config loader (WEB_ORIGINS)', () => {
225
+ const { loader, toml } = inSyncFixture();
226
+ const extraToml = toml.replace(
227
+ '## END consumed-env-vars',
228
+ '## [vars] WEB_ORIGINS — read by the worker edge, not the loader\n## END consumed-env-vars',
229
+ );
230
+ expect(checkEnvVarDrift(loader, extraToml)).toEqual({ ok: true, documentedCount: 4 });
231
+ expect(EXTRA_DOCUMENTED_ENV_VARS.has('WEB_ORIGINS')).toBe(true);
232
+ });
233
+
234
+ it('flags a credential var classified [vars] instead of [secret]', () => {
235
+ const { loader, toml } = inSyncFixture();
236
+ const misclassified = toml.replace(
237
+ '## [secret] OPER_PASSWORD — oper credential',
238
+ '## [vars] OPER_PASSWORD — oper credential',
239
+ );
240
+ const result = checkEnvVarDrift(loader, misclassified);
241
+ expect(result.ok).toBe(false);
242
+ if (result.ok) return;
243
+ expect(result.errors.length).toBe(1);
244
+ expect(result.errors[0]).toContain('OPER_PASSWORD');
245
+ expect(result.errors[0]).toContain('credential material');
246
+ });
247
+
248
+ it('does not double-flag a missing required secret as misclassified', () => {
249
+ const { loader, toml } = inSyncFixture();
250
+ const removed = toml.replace('## [secret] SASL_ACCOUNTS — SASL seed list\n', '');
251
+ const result = checkEnvVarDrift(loader, removed);
252
+ expect(result.ok).toBe(false);
253
+ if (result.ok) return;
254
+ expect(result.errors.length).toBe(1);
255
+ expect(result.errors[0]).toContain('not documented');
256
+ });
257
+
258
+ it('flags a [secret] var that also ships as a plaintext [vars] key', () => {
259
+ const { loader, toml } = inSyncFixture();
260
+ const leaked = toml.replace(
261
+ 'SERVER_NAME = "irc.example.net"',
262
+ 'SERVER_NAME = "irc.example.net"\nOPER_PASSWORD = "hunter2"',
263
+ );
264
+ const result = checkEnvVarDrift(loader, leaked);
265
+ expect(result.ok).toBe(false);
266
+ if (result.ok) return;
267
+ expect(result.errors.length).toBe(1);
268
+ expect(result.errors[0]).toContain('OPER_PASSWORD');
269
+ expect(result.errors[0]).toContain('wrangler secret put');
270
+ });
271
+
272
+ it('aggregates multiple independent findings', () => {
273
+ const loader = 'export interface CfConfigEnv {\n NEW_KNOB?: string;\n}';
274
+ const toml = [
275
+ '## BEGIN consumed-env-vars',
276
+ '## [vars] STALE_KNOB — gone',
277
+ '## END consumed-env-vars',
278
+ ].join('\n');
279
+ const result = checkEnvVarDrift(loader, toml);
280
+ expect(result.ok).toBe(false);
281
+ if (result.ok) return;
282
+ expect(result.errors.length).toBe(2);
283
+ });
284
+ });
@@ -8,7 +8,11 @@ export default defineConfig({
8
8
  provider: 'v8',
9
9
  all: false,
10
10
  include: ['src/**/*.ts'],
11
- exclude: ['src/index.ts'],
11
+ // `src/index.ts` is a pure barrel; `src/hostname-guard.ts` and
12
+ // `src/cf-deploy-cli.ts` are the side-effectful CLI shims
13
+ // (argv/env wiring + process.exit) whose logic lives in
14
+ // `deploy-hostname.ts` / `cf-deploy.ts`.
15
+ exclude: ['src/index.ts', 'src/hostname-guard.ts', 'src/cf-deploy-cli.ts'],
12
16
  reporter: ['text', 'html', 'json-summary'],
13
17
  thresholds: {
14
18
  lines: 100,