serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -12,7 +12,7 @@ the two are independent and a deployment can enable either or both.
12
12
  > This doc covers the *how*.
13
13
 
14
14
  **Acceptance criterion (TICKET-058):** a new contributor can deploy
15
- their own TCP+TLS staging instance following only this doc.
15
+ their own TCP+TLS instance following only this doc.
16
16
 
17
17
  Cross-reference: `apps/cf-tcp-container/` (origin),
18
18
  `apps/cf-tcp-container/wrangler.toml` (container config),
@@ -36,22 +36,32 @@ path adds two stateful pieces in front of the shared IRC core:
36
36
  ┌──────────────────────────────────────────────────────────────┐
37
37
  │ Cloudflare Spectrum (Enterprise) │
38
38
  │ • terminates TLS on :6697 │
39
- │ • forwards plaintext TCP to the origin
39
+ │ • re-encrypts to the origin (tls_mode = "full")
40
40
  │ • PROXY protocol v1 carries the real client IP │
41
41
  └──────────────────────────┬───────────────────────────────────┘
42
- plaintext TCP (:6667)
42
+ TLS (:6697, origin cert)
43
43
 
44
44
  ┌──────────────────────────────────────────────────────────────┐
45
45
  │ Cloudflare Container (apps/cf-tcp-container) │
46
- │ • node:24 process, EXPOSE 6667
46
+ │ • node:24 process, EXPOSE 6697 — terminates TLS itself
47
+ │ (TLS_CERT_PATH / TLS_KEY_PATH, mounted at /data/certs) │
47
48
  │ • ConnectionActor + InMemoryRuntime + TcpByteStream- │
48
49
  │ Transport (the shared transport seam, TICKET-053) │
49
50
  │ • persistence snapshot at /data/state.json │
50
51
  │ (channel topology survives restart; live connections │
51
52
  │ do not — clients reconnect) │
53
+ │ • plaintext :6667 = localhost-only debug listener │
54
+ │ (DEBUG_PLAINTEXT=localhost; never routed, never in │
55
+ │ prod — a prod-classified boot refuses to start │
56
+ │ without TLS material) │
52
57
  └──────────────────────────────────────────────────────────────┘
53
58
  ```
54
59
 
60
+ TLS is **end-to-end**: no plaintext IRC bytes ever cross the origin
61
+ network, so an attacker who can reach the origin address directly can
62
+ neither read `PASS`/`AUTHENTICATE`/`PRIVMSG` off the wire nor spoof
63
+ them.
64
+
55
65
  The **parser → reducer → dispatch pipeline is shared verbatim** with the
56
66
  wss path. Only the line-framing transport differs: a `TcpByteStreamTransport`
57
67
  reassembles `\r\n`-terminated IRC lines across arbitrary TCP chunks
@@ -61,8 +71,8 @@ Two artifacts are deployed and configured:
61
71
 
62
72
  | Artifact | Tool | What it does |
63
73
  |---------------------------------------|----------------|------------------------------------------------|
64
- | `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image. |
65
- | `cloudflare_spectrum_application` | `terraform` | TLS termination on :6697 + origin forwarding. |
74
+ | `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image (TLS origin). |
75
+ | `cloudflare_spectrum_application` | `terraform` | TLS on :6697 + re-encrypted origin forwarding (`tls_mode = "full"`). |
66
76
 
67
77
  ---
68
78
 
@@ -77,7 +87,7 @@ Two artifacts are deployed and configured:
77
87
  | **Cloudflare Enterprise**| **Spectrum is an Enterprise-tier feature.** Required for the TCP path. |
78
88
  | Containers beta access | Cloudflare Containers is in beta; request access in the dashboard. |
79
89
  | A Cloudflare zone | The public hostname (e.g. `irc.example.com`) must live in a zone you control. |
80
- | Git checkout | Clean working tree on `main` for production deploys. |
90
+ | Git checkout | Clean working tree on `main` for deploys. |
81
91
 
82
92
  > The wss path runs on the Workers Free/Paid plan. The TCP path does
83
93
  > **not** — Spectrum mandates Enterprise. If you do not have Enterprise,
@@ -133,17 +143,19 @@ export CLOUDFLARE_ACCOUNT_ID=...
133
143
 
134
144
  ## 4. Local development
135
145
 
136
- Run the container origin locally without Spectrum connect a plaintext
137
- TCP IRC client directly to :6667. This exercises the exact
138
- `ConnectionActor` + `TcpByteStreamTransport` pipeline.
146
+ Run the container origin locally without Spectrum. The plaintext TCP
147
+ listener is **debug-only and localhost-only**: it starts solely when
148
+ `DEBUG_PLAINTEXT=localhost` is set (the config loader then forces the
149
+ bind to `127.0.0.1` — `TCP_HOST` cannot widen it). This exercises the
150
+ exact `ConnectionActor` + `TcpByteStreamTransport` pipeline.
139
151
 
140
152
  ```bash
141
153
  # Build the workspace + the container package.
142
154
  pnpm build
143
155
 
144
- # Run the origin on plaintext :6667 (default TCP_PORT).
145
- pnpm --filter @serverless-ircd/cf-tcp-container start \
146
- -- --env-file <(printf 'SERVER_NAME=irc.local\nTCP_PORT=6667\n')
156
+ # Run the origin with the localhost-only plaintext debug listener.
157
+ DEBUG_PLAINTEXT=localhost SERVER_NAME=irc.local TCP_PORT=6667 \
158
+ pnpm --filter @serverless-ircd/cf-tcp-container start
147
159
  ```
148
160
 
149
161
  Connect a plaintext client (no TLS) to `localhost:6667`:
@@ -156,12 +168,19 @@ JOIN #test
156
168
  PRIVMSG #test :hello over plaintext TCP
157
169
  ```
158
170
 
159
- For a TLS-terminated local loop, put `stunnel` (or `openssl s_server`)
160
- in front of :6667 and connect on :6697 mirroring the Spectrum topology.
171
+ For a TLS local loop that mirrors production (TLS terminating *at the
172
+ origin*), point the origin at a local cert pair instead no debug
173
+ flag needed:
174
+
175
+ ```bash
176
+ TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key SERVER_NAME=irc.local \
177
+ pnpm --filter @serverless-ircd/cf-tcp-container start
178
+ # → TLS listener on :6697; plaintext stays closed.
179
+ ```
161
180
 
162
181
  The unit + the TLS e2e suites live in `apps/cf-tcp-container/tests/`
163
- (including `tls-e2e.test.ts`, which runs a real TLS handshake against the
164
- origin). Run them with:
182
+ (including `tls-e2e.test.ts` and `container-server-tls.test.ts`, which
183
+ run real TLS handshakes against the origin). Run them with:
165
184
 
166
185
  ```bash
167
186
  pnpm --filter @serverless-ircd/cf-tcp-container test
@@ -169,27 +188,58 @@ pnpm --filter @serverless-ircd/cf-tcp-container test
169
188
 
170
189
  ---
171
190
 
172
- ## 5. Deploy staging
191
+ ## 5. Deploy
173
192
 
174
- Staging is what CI deploys on every push that touches
175
- `apps/cf-tcp-container/**`. You can also deploy it manually.
193
+ The deploy is `workflow_dispatch`-only (a maintainer triggers it by
194
+ hand); you can also deploy manually.
176
195
 
177
196
  ### 5.1 Deploy the container origin
178
197
 
179
198
  ```bash
180
199
  # From the repo root:
181
- pnpm deploy:cf-tcp:staging
200
+ pnpm deploy:cf-tcp
182
201
  # Equivalent to:
183
- # pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging
184
- # → wrangler deploy --env staging (inside apps/cf-tcp-container)
202
+ # pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy
203
+ # → wrangler deploy (inside apps/cf-tcp-container)
185
204
  ```
186
205
 
187
206
  This builds the container image from `apps/cf-tcp-container/Dockerfile`
188
- and deploys the `sirc-tcp-origin-staging` Container. The first deploy
189
- prints the **origin address** — copy it into `CF_TCP_ORIGIN_ADDRESS`
190
- (used by the Spectrum step next).
191
-
192
- ### 5.2 Apply the Spectrum TLS config
207
+ and deploys the `sirc-tcp-origin` Container. The first deploy prints
208
+ the **origin address** — copy it into `CF_TCP_ORIGIN_ADDRESS` (used by
209
+ the Spectrum step next).
210
+
211
+ ### 5.2 Provision the origin certificate
212
+
213
+ `tls_mode = "full"` means the **origin terminates TLS itself**, so it
214
+ needs a PEM cert/key pair. Mount it at `/data/certs/` (the image's
215
+ documented default: `TLS_CERT_PATH=/data/certs/tls.pem`,
216
+ `TLS_KEY_PATH=/data/certs/tls.key`; override via env if the pair lives
217
+ elsewhere). Cert files must be readable by UID 1000 (`node`) — the
218
+ container drops privileges before reading them. Any of these work:
219
+
220
+ | Option | How | Notes |
221
+ |------------------------|----------------------------------------------------------------|-------|
222
+ | **Cloudflare Origin Cert** | Dashboard → SSL/TLS → Origin Server → Create Certificate. | Free, long-lived, issued for your zone; the easiest option when the origin is only reached via Spectrum. Not publicly trusted (fine — clients validate the *edge* cert). |
223
+ | **Let's Encrypt** | `certbot certonly --dns-plugin … -d origin.example.com`, then copy `fullchain.pem`/`privkey.pem` to `/data/certs/tls.{pem,key}` and renew on a cron/sidecar. | Publicly trusted; needs automation for the 90-day rotation. |
224
+ | **BYO cert** | Any PEM pair from your CA. | Your compliance rules apply. |
225
+
226
+ > A prod-classified boot (`NODE_ENV=production`, set by the Dockerfile)
227
+ > **refuses to start** without `TLS_CERT_PATH`/`TLS_KEY_PATH` — there is
228
+ > no silent plaintext fallback. A missing/unreadable cert file also
229
+ > fails fast at boot with the offending path in the error.
230
+
231
+ ### 5.3 Lock origin ingress down to Cloudflare IPs
232
+
233
+ Spectrum is the only thing that should ever reach the origin's :6697.
234
+ On any platform with a firewall / security-group primitive, restrict
235
+ ingress to Cloudflare's published ranges (https://www.cloudflare.com/ips/)
236
+ — otherwise a direct-reach attacker skips the edge entirely.
237
+ `spectrum.tf` ships a commented `cloudflare_list` reference for keeping
238
+ a managed list of the current ranges; Cloudflare Containers origins
239
+ rely on Spectrum `ip_firewall` + the platform's own network isolation,
240
+ but external-compute origins must apply the firewall on their side.
241
+
242
+ ### 5.4 Apply the Spectrum TLS config
193
243
 
194
244
  ```bash
195
245
  cd apps/cf-tcp-container/terraform
@@ -197,28 +247,29 @@ terraform init
197
247
  terraform apply \
198
248
  -var zone_id=$CF_ZONE_ID \
199
249
  -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
200
- -var hostname=irc-staging.example.com
250
+ -var hostname=irc.example.com
201
251
  ```
202
252
 
203
253
  This creates the `cloudflare_spectrum_application.irc_tls` resource:
204
- TLS on :6697 plaintext TCP to the container origin on :6667.
254
+ TLS on :6697 at the edge, **re-encrypted** to the container origin's
255
+ TLS listener on :6697 (`tls_mode = "full"` — the secure default).
205
256
 
206
- ### 5.3 Smoke test over TLS
257
+ ### 5.5 Smoke test over TLS
207
258
 
208
259
  ```bash
209
260
  # Minimal smoke: register + quit over the real TLS endpoint.
210
261
  printf 'NICK smoke\r\nUSER smoke 0 * :Smoke\r\nQUIT :smoke\r\n' | \
211
- timeout 10 openssl s_client -connect irc-staging.example.com:6697 -quiet | \
262
+ timeout 10 openssl s_client -connect irc.example.com:6697 -quiet | \
212
263
  grep -q '001' && echo "SMOKE PASS" || echo "SMOKE FAIL"
213
264
  ```
214
265
 
215
266
  CI does the same — see `.github/workflows/deploy-cf-tcp.yml` step
216
267
  "Smoke e2e (irc+tls)", gated on the `CF_TCP_SMOKE_HOST` repo variable.
217
268
 
218
- ### 5.4 Connect a real IRC client
269
+ ### 5.6 Connect a real IRC client
219
270
 
220
271
  ```
221
- /server add ircd irc-staging.example.com/6697
272
+ /server add ircd irc.example.com/6697
222
273
  /set irc.server.ircd.ssl on
223
274
  /connect ircd
224
275
  /join #test
@@ -226,27 +277,6 @@ CI does the same — see `.github/workflows/deploy-cf-tcp.yml` step
226
277
 
227
278
  ---
228
279
 
229
- ## 6. Deploy production
230
-
231
- Production is the default `wrangler.toml` environment (no `--env` flag).
232
-
233
- ```bash
234
- # 1. Edit wrangler.toml [vars] for production identity.
235
- # 2. Deploy the container:
236
- pnpm deploy:cf-tcp:prod
237
- # 3. Apply Spectrum config for the production hostname:
238
- cd apps/cf-tcp-container/terraform
239
- terraform apply -var zone_id=$CF_ZONE_ID \
240
- -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
241
- -var hostname=irc.example.com
242
- ```
243
-
244
- There is **no CI auto-deploy to production** — production deploys are
245
- manual. Staging deploys on every push to `main` that touches the
246
- container path.
247
-
248
- ---
249
-
250
280
  ## 7. Configuration reference
251
281
 
252
282
  ### 7.1 Container — `apps/cf-tcp-container/wrangler.toml`
@@ -261,8 +291,13 @@ Top-level `[vars]` map directly to the container config loader
261
291
  | `SERVER_VERSION` | Version string in `002`. | (from package version) |
262
292
  | `CREATED_AT` | Creation text/time in `003`. | build/deploy timestamp |
263
293
  | `MOTD_LINES` | Message-of-the-day, `\n`-delimited. | welcome banner |
264
- | `TCP_PORT` | Plaintext port the container listens on. | `6667` |
265
- | `TCP_HOST` | Bind address (Spectrum forwards from the edge). | `0.0.0.0` |
294
+ | `NODE_ENV` | `production` classifies the boot as prod: the loader then refuses to start without TLS material and rejects `DEBUG_PLAINTEXT`. The Dockerfile sets it. | (unset = dev) |
295
+ | `TLS_CERT_PATH` | PEM certificate the origin TLS listener presents. **Required in prod** (with `TLS_KEY_PATH`). | image: `/data/certs/tls.pem` |
296
+ | `TLS_KEY_PATH` | PEM private key for the origin TLS listener. **Required in prod** (with `TLS_CERT_PATH`). | image: `/data/certs/tls.key` |
297
+ | `TLS_PORT` | Origin TLS listener port. | `6697` |
298
+ | `TCP_PORT` | Plaintext debug-listener port — only used when `DEBUG_PLAINTEXT=localhost` is set. | `6667` |
299
+ | `TCP_HOST` | Bind address. Forced to `127.0.0.1` whenever the plaintext debug listener is enabled. | `0.0.0.0` |
300
+ | `DEBUG_PLAINTEXT` | Exact value `localhost` opts into the localhost-only plaintext debug listener. Any other value is rejected; forbidden in prod. | (unset = TLS-only) |
266
301
  | `PERSISTENCE_PATH` | Snapshot file path; enables channel-state survival. | `/data/state.json` |
267
302
  | `SNAPSHOT_INTERVAL_MS`| Auto-snapshot cadence (ms). | `60000` |
268
303
  | `OPER_USER` / `OPER_PASSWORD` | Oper credentials for `OPER`. | — |
@@ -294,10 +329,10 @@ sleep_after = "2h"
294
329
  |-------------------|---------------------------------------------------|---------------------|
295
330
  | `zone_id` | Cloudflare zone for the hostname. | *(required)* |
296
331
  | `origin_address` | Hostname/IP of the container origin. | *(required)* |
297
- | `origin_port` | Port the origin listens on (plaintext). | `6667` |
332
+ | `origin_port` | Port the origin's **TLS** listener serves. | `6697` |
298
333
  | `hostname` | Public hostname clients connect to. | `irc.example.com` |
299
334
  | `spectrum_port` | Public port Spectrum listens on. | `6697` |
300
- | `tls_mode` | `flexible` (TLS→CF, plain→origin) / `full` / `strict`. | `flexible` |
335
+ | `tls_mode` | `full` (TLS end-to-end — the secure default) / `strict` (edge also validates the origin cert). | `full` |
301
336
 
302
337
  `ip_firewall = true` (Cloudflare's L3/L4 DDoS) and
303
338
  `argo_smart_routing = true` are on by default. `proxy_protocol = "v1"`
@@ -332,24 +367,39 @@ path (Durable Objects) does not have. Two layers of survival:
332
367
 
333
368
  ---
334
369
 
335
- ## 9. mTLS (SASL EXTERNAL)
370
+ ## 9. mTLS (SASL EXTERNAL) — not supported on this transport
371
+
372
+ SASL `EXTERNAL` requires the verified client certificate to reach the
373
+ application layer. On the TCP container path it cannot: Spectrum
374
+ forwards to the origin with **PROXY protocol v1**, which carries no
375
+ client-cert subject, and although `tls_mode = "full"` terminates TLS at
376
+ the origin (`TLS_CERT_PATH`/`TLS_KEY_PATH`), no client-certificate
377
+ trust pool is wired through to the IRC core — there is no
378
+ `MtlsIdentityProvider` to feed, in either TLS mode.
336
379
 
337
- SASL `EXTERNAL` needs the client certificate to reach the application
338
- layer (ADR-006, TICKET-054). On the TCP path this is the TLS handshake:
380
+ The container therefore pins the mechanism off explicitly via the
381
+ `sasl.externalUnsupportedMessage` transport override:
339
382
 
340
- - **Flexible mode (default):** Spectrum terminates TLS; the origin sees
341
- plaintext. The cert is consumed at the edge and the app only gets a
342
- verified subject via Cloudflare's mTLS config — SASL `EXTERNAL` is
343
- **not** available in flexible mode.
344
- - **Full mode:** set `tls_mode = "full"` and configure the origin to
345
- present its own certificate; configure Spectrum/API Shield mTLS with
346
- your CA pool. The verified client-cert subject is then exposed to the
347
- origin, feeding the `MtlsIdentityProvider` port → `AccountStore` →
348
- `AUTHENTICATE EXTERNAL` succeeds (`903`) or fails (`904`).
383
+ - the `sasl` capability advertises `PLAIN` only, and
384
+ - a client sending `AUTHENTICATE EXTERNAL` is rejected up front with
385
+ `904 ERR_SASLFAIL` carrying the stable message
386
+ `SASL EXTERNAL not supported on this transport`.
349
387
 
350
- Cross-reference: TICKET-054 (mTLS support), ADR-006 (SASL scope). The wss
351
- path achieves the same via CF API Shield mTLS (see
352
- `Cloudflare-Deployment-Guide.md`).
388
+ The refusal does not count toward the per-connection SASL failure
389
+ lockout. Clients that need certificate-backed SASL should use the wss
390
+ path (CF API Shield mTLS — see `Cloudflare-Deployment-Guide.md`) or the
391
+ AWS NLB+TLS path (API Gateway custom-domain mTLS — see
392
+ `AWS-TCP-Deployment.md`). Forwarding the client-cert subject via
393
+ PROXY-protocol-v2 TLVs (which Spectrum can emit) is the documented
394
+ follow-up if demand materialises; see `SASL-EXTERNAL.md`.
395
+
396
+ - **Flexible mode (explicit downgrade):** Spectrum terminates TLS and
397
+ forwards plaintext TCP to the origin. EXTERNAL is equally unavailable
398
+ there, and plaintext origin traffic reintroduces the direct-reach
399
+ exposure this topology closed. Avoid it.
400
+
401
+ Cross-reference: ADR-006 (SASL scope), `SASL-EXTERNAL.md` (mechanism
402
+ scope + transport availability).
353
403
 
354
404
  ---
355
405
 
@@ -360,7 +410,7 @@ and SASL credentials ship as `[vars]` (`OPER_*`, `SASL_ACCOUNTS`). When
360
410
  strong-secret handling lands, set them via:
361
411
 
362
412
  ```bash
363
- wrangler secret put OPER_PASSWORD --env staging
413
+ wrangler secret put OPER_PASSWORD
364
414
  ```
365
415
 
366
416
  CI uses these **GitHub Actions secrets/variables** (configure under
@@ -384,19 +434,20 @@ Settings → Secrets and variables → Actions):
384
434
  (the wss path). The two are independent — a deployment can enable
385
435
  either or both.
386
436
 
387
- Triggers: push to `main` touching `apps/cf-tcp-container/**` or the
388
- workflow itself, plus `workflow_dispatch`. Steps (in order):
437
+ Triggers: `workflow_dispatch` only (a maintainer triggers it by hand;
438
+ there is no automatic push-to-main deploy). Steps (in order):
389
439
 
390
440
  1. Checkout → pnpm 11 + Node 24 → `pnpm install --frozen-lockfile`.
391
441
  2. `pnpm build`, `pnpm typecheck`, `pnpm --filter cf-tcp-container test`.
392
- 3. `wrangler deploy --env staging` (container).
442
+ 3. `wrangler deploy` (container).
393
443
  4. `terraform init && terraform apply -auto-approve` (Spectrum).
394
444
  5. TLS smoke (`openssl s_client`), gated on `CF_TCP_SMOKE_HOST`.
395
445
 
396
- Concurrency is serialized via `concurrency.group: cf-tcp-staging`.
446
+ Concurrency is serialized via `concurrency.group: cf-tcp-deploy`.
397
447
 
398
- There is **no auto-deploy to production**; production is manual
399
- (`pnpm deploy:cf-tcp:prod` + `terraform apply` for the prod hostname).
448
+ There is a single deploy target the staging/prod split was collapsed,
449
+ so the same workflow + account ships to whichever Container namespace
450
+ the credentials target.
400
451
 
401
452
  ---
402
453
 
@@ -409,7 +460,7 @@ because of the stateful origin:
409
460
  |------------------------|------------------------------------|--------------------------------------------|
410
461
  | **Plan tier** | Workers Paid ($5/mo) suffices. | **Enterprise required** (Spectrum). |
411
462
  | **Idle connections** | Hibernation → ~zero memory cost. | Origin holds every socket; `sleep_after` only helps when *all* connections are idle. |
412
- | **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time. |
463
+ | **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time. `tls_mode = "full"` moves TLS termination into the origin container, so it now pays the per-connection handshake CPU — budget handshakes/sec (each reconnect = one handshake) against the container's compute allocation when sizing `instance_type`. |
413
464
  | **State durability** | DO storage (managed). | Container volume snapshot (self-managed). |
414
465
  | **DDoS** | Cloudflare built-in. | Spectrum `ip_firewall` + Argo. |
415
466
 
@@ -437,10 +488,19 @@ respond. Causes:
437
488
  - `CF_TCP_ORIGIN_ADDRESS` points at the wrong origin. It must match the
438
489
  address printed by `wrangler deploy` (§5.1).
439
490
  - The origin crashed on boot. Check `wrangler tail` for the container
440
- (or container logs in the dashboard). A common cause is an invalid
441
- `MOTD_LINES` or a malformed `SASL_ACCOUNTS` line.
491
+ (or container logs in the dashboard). Common causes: the cert pair is
492
+ missing/unreadable at `TLS_CERT_PATH`/`TLS_KEY_PATH` (prod boots
493
+ refuse to start without it — the error names the offending path), an
494
+ invalid `MOTD_LINES`, or a malformed `SASL_ACCOUNTS` line.
495
+
496
+ ### 13.3 Origin refuses to boot: "refusing to start a prod-classified origin without TLS"
442
497
 
443
- ### 13.3 Channel topic/modes lost after a restart
498
+ `NODE_ENV=production` is set (the Dockerfile sets it by default) but
499
+ `TLS_CERT_PATH`/`TLS_KEY_PATH` are not. Provision a cert pair (§5.2),
500
+ mount it at `/data/certs/`, and restart. There is deliberately no
501
+ plaintext fallback in prod.
502
+
503
+ ### 13.4 Channel topic/modes lost after a restart
444
504
 
445
505
  `PERSISTENCE_PATH` is unset or the volume is not attached. Confirm
446
506
  `PERSISTENCE_PATH=/data/state.json` in `[vars]` and that the `/data`
@@ -448,13 +508,13 @@ volume (`VOLUME ["/data"]` in the `Dockerfile`) is mounted on the
448
508
  container platform. Without it the snapshot is written to ephemeral
449
509
  storage and lost.
450
510
 
451
- ### 13.4 Client IP seen as Spectrum's address
511
+ ### 13.5 Client IP seen as Spectrum's address
452
512
 
453
513
  `proxy_protocol = "v1"` must be set in `spectrum.tf` (it is by default).
454
514
  If you overrode it, the origin's per-IP admission gate sees Spectrum's
455
515
  egress IP for every client and will wrongly throttle/cap.
456
516
 
457
- ### 13.5 Messages split across chunks get lost
517
+ ### 13.6 Messages split across chunks get lost
458
518
 
459
519
  This is a transport-layer bug, not a config issue — the
460
520
  `TcpByteStreamTransport` is contract-tested for chunk boundaries
@@ -462,7 +522,7 @@ This is a transport-layer bug, not a config issue — the
462
522
  `pnpm --filter @serverless-ircd/cf-tcp-container test` and the shared
463
523
  transport suite; a regression there will fail.
464
524
 
465
- ### 13.6 `wrangler deploy` fails: `Containers beta not enabled`
525
+ ### 13.7 `wrangler deploy` fails: `Containers beta not enabled`
466
526
 
467
527
  Cloudflare Containers is in beta. Request access in the dashboard
468
528
  (Workers & Pages → Containers). Until granted, only the local Docker
@@ -477,20 +537,19 @@ path (§4) and the wss transport work.
477
537
  npx wrangler login # or set CLOUDFLARE_API_TOKEN
478
538
  terraform version # >= 1.5
479
539
 
480
- # Local dev (plaintext :6667, no Spectrum)
540
+ # Local dev (TLS origin on :6697 with a local cert pair)
481
541
  pnpm build
482
- pnpm --filter @serverless-ircd/cf-tcp-container start
542
+ TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key \
543
+ pnpm --filter @serverless-ircd/cf-tcp-container start
544
+ # …or the localhost-only plaintext debug listener:
545
+ DEBUG_PLAINTEXT=localhost pnpm --filter @serverless-ircd/cf-tcp-container start
483
546
 
484
- # Staging
485
- pnpm deploy:cf-tcp:staging # container
547
+ # Deploy (single target — staging vs prod is which account you point at)
548
+ pnpm deploy:cf-tcp # container
486
549
  cd apps/cf-tcp-container/terraform && \
487
550
  terraform apply -var zone_id=$CF_ZONE_ID \
488
551
  -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
489
- -var hostname=irc-staging.example.com # Spectrum
490
-
491
- # Production (manual)
492
- pnpm deploy:cf-tcp:prod
493
- cd apps/cf-tcp-container/terraform && terraform apply ... # prod hostname
552
+ -var hostname=irc.example.com # Spectrum (tls_mode=full)
494
553
 
495
554
  # Tests
496
555
  pnpm --filter @serverless-ircd/cf-tcp-container test
@@ -501,10 +560,10 @@ Key files:
501
560
  | Path | What |
502
561
  |-------------------------------------------------|--------------------------------------------|
503
562
  | `apps/cf-tcp-container/src/main.ts` | Container entry point. |
504
- | `apps/cf-tcp-container/src/container-server.ts` | TCP server + actor wiring + persistence. |
505
- | `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs. |
563
+ | `apps/cf-tcp-container/src/container-server.ts` | TCP/TLS listeners + actor wiring + persistence. |
564
+ | `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs (TLS + plaintext-debug gates). |
506
565
  | `apps/cf-tcp-container/src/persistence.ts` | Channel-topology snapshot/restore. |
507
566
  | `apps/cf-tcp-container/wrangler.toml` | Container deploy config. |
508
- | `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :6667). |
509
- | `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin :6667. |
567
+ | `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :6697, UID 1000). |
568
+ | `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin TLS :6697 (`full`). |
510
569
  | `.github/workflows/deploy-cf-tcp.yml` | Staging deploy + Spectrum + smoke CI. |
@@ -37,7 +37,7 @@ this repo itself) should pin exact versions.
37
37
 
38
38
  ### 1.1 Workspace version lock
39
39
 
40
- All fifteen `package.json` files (root + 14 workspace packages) carry the
40
+ All sixteen `package.json` files (root + 15 workspace packages) carry the
41
41
  **same** version string. They are bumped together in the release
42
42
  commit. The pre-release checklist in §4 enforces this.
43
43
 
@@ -63,8 +63,8 @@ not always possible.
63
63
  | Push to `main` | Maintainers | Merging the release PR. |
64
64
  | Push tags (`v*`) | Maintainers | `git push origin v0.x.0`. |
65
65
  | Create GitHub Release | Maintainers | Publishing the release notes. |
66
- | `CLOUDFLARE_API_TOKEN` secret | Repo Settings → Secrets | Local prod deploys. CI staging deploys. |
67
- | `CF_SMOKE_URL` variable | Repo Settings → Variables | Staging smoke e2e in CI. |
66
+ | `CLOUDFLARE_API_TOKEN` secret | Repo Settings → Secrets | Manual + CI deploys (single target). |
67
+ | `CF_SMOKE_URL` variable | Repo Settings → Variables | Smoke e2e in CI (manual dispatch). |
68
68
 
69
69
  There is no separate "release engineer" role. The maintainer who cuts
70
70
  the tag owns the release end-to-end, including the production deploy
@@ -101,7 +101,7 @@ git pull --ff-only origin main
101
101
  # 2. CI is green on the tip of main.
102
102
  # Open: https://github.com/<owner>/<repo>/actions
103
103
  # - CI workflow: lint / typecheck / coverage all green.
104
- # - deploy-cf workflow: staging deploy + smoke e2e green.
104
+ # - deploy-cf workflow: deploy + smoke e2e green (manual dispatch).
105
105
 
106
106
  # 3. Local full verify.
107
107
  pnpm install --frozen-lockfile
@@ -120,9 +120,9 @@ pnpm build # every package produces dist/
120
120
  # be green (step 2), so locally re-run `pnpm coverage` as a
121
121
  # belt-and-braces check before tagging.
122
122
 
123
- # 5. Staging smoke e2e passes against the deployed tip-of-main.
123
+ # 5. Smoke e2e passes against the deployed tip-of-main.
124
124
  node apps/cf-worker/scripts/smoke.mjs \
125
- --url wss://serverless-ircd-staging.<subdomain>.workers.dev
125
+ --url wss://serverless-ircd.<subdomain>.workers.dev
126
126
 
127
127
  # 6. CHANGELOG.md is complete and accurate (§5).
128
128
  # 7. All package.json versions are bumped and lock-step (§6).
@@ -187,8 +187,9 @@ understand what changed and what they need to do.
187
187
 
188
188
  ## 6. Bumping versions
189
189
 
190
- There are fifteen `package.json` files. They must end up at the same
191
- version:
190
+ There are sixteen `package.json` files (root + 15 workspace packages).
191
+ They must end up at the same version (`tools/package.json` is a parent
192
+ container with no `version` field and is not bumped):
192
193
 
193
194
  ```
194
195
  ./package.json (workspace root)
@@ -206,6 +207,7 @@ apps/web/package.json
206
207
  tools/tcp-ws-forwarder/package.json
207
208
  tools/load-test/package.json
208
209
  tools/ci-hardening/package.json
210
+ scripts/package.json (deploy helpers)
209
211
  ```
210
212
 
211
213
  To cut `v0.X.Y`:
@@ -217,8 +219,8 @@ git checkout -b release/v0.X.Y
217
219
  # bump each file. The version field is the only change.
218
220
  # (Optional helper: `pnpm -r exec -- node -e '...'` if you script it.)
219
221
 
220
- # verify all eight files agree:
221
- rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json
222
+ # verify all files agree:
223
+ rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json scripts/package.json
222
224
  ```
223
225
 
224
226
  The lockfile (`pnpm-lock.yaml`) is unaffected — none of the workspace
@@ -270,7 +272,7 @@ git pull --ff-only origin main
270
272
  git log -1 --oneline # expect: chore(release): v0.X.Y
271
273
 
272
274
  # Confirm versions are at the new release.
273
- rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json
275
+ rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json scripts/package.json
274
276
  ```
275
277
 
276
278
  ### 8.1 Tag and push
@@ -299,8 +301,10 @@ configured (`git config tag.gpgSign true`).
299
301
 
300
302
  ### 8.3 Production deploy (manual)
301
303
 
302
- There is **no CI auto-deploy to production**. The maintainer runs the
303
- prod deploy by hand, from a clean checkout on the tag:
304
+ There is a single deploy target per platform (`pnpm deploy:cf`); the
305
+ staging/prod split was collapsed, so the same command ships to whichever
306
+ Cloudflare account the credentials target. The maintainer runs the
307
+ deploy by hand, from a clean checkout on the tag:
304
308
 
305
309
  ```bash
306
310
  git checkout v0.X.Y
@@ -313,17 +317,17 @@ pnpm build
313
317
  pnpm typecheck
314
318
  pnpm test
315
319
 
316
- # Deploy the Cloudflare Worker to production.
317
- pnpm deploy:cf:prod
318
- # → wrangler deploy (default env = production)
320
+ # Deploy the Cloudflare Worker.
321
+ pnpm deploy:cf
322
+ # → wrangler deploy
319
323
 
320
- # Tail prod logs while the first connections arrive.
324
+ # Tail logs while the first connections arrive.
321
325
  npx wrangler tail
322
326
 
323
- # Smoke-check prod with the same script CI uses on staging.
324
- # You must point it at the prod wss:// URL:
327
+ # Smoke-check the deploy with the same script CI uses.
328
+ # You must point it at the wss:// URL:
325
329
  node apps/cf-worker/scripts/smoke.mjs \
326
- --url wss://<prod-workers-subdomain>.workers.dev
330
+ --url wss://<workers-subdomain>.workers.dev
327
331
  ```
328
332
 
329
333
  If smoke fails on prod:
@@ -355,7 +359,7 @@ To redeploy a previous release:
355
359
  git checkout v0.W.Z # the previous tag
356
360
  pnpm install --frozen-lockfile
357
361
  pnpm build
358
- pnpm deploy:cf:prod
362
+ pnpm deploy:cf
359
363
  ```
360
364
 
361
365
  This works as long as the previous release's code is compatible with
@@ -430,7 +434,7 @@ pnpm lint && pnpm typecheck && pnpm test && pnpm build
430
434
 
431
435
  # 2. Branch, bump versions + CHANGELOG, open PR (§6, §7).
432
436
  git checkout -b release/v0.X.Y
433
- # ... edit 8 × package.json + CHANGELOG.md ...
437
+ # ... edit 16 × package.json + CHANGELOG.md ...
434
438
  git commit -am "chore(release): v0.X.Y"
435
439
  git push -u origin release/v0.X.Y
436
440
  # ... open PR, get it merged ...
@@ -445,7 +449,7 @@ git push origin v0.X.Y
445
449
  # 5. Prod deploy (§8.3).
446
450
  git checkout v0.X.Y
447
451
  pnpm install --frozen-lockfile && pnpm build
448
- pnpm deploy:cf:prod
452
+ pnpm deploy:cf
449
453
  node apps/cf-worker/scripts/smoke.mjs --url wss://<prod>.workers.dev
450
454
  ```
451
455