serverless-ircd 0.5.0 → 0.6.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 (167) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,443 @@
1
+ # Release Process
2
+
3
+ End-to-end runbook for cutting, publishing, and rolling back a
4
+ ServerlessIRCd release. This is the canonical process — every release
5
+ follows this document top-to-bottom.
6
+
7
+ **Distribution model.** All workspace packages are `"private": true`. A
8
+ release is a **signed git tag** plus a **GitHub Release** whose body is
9
+ copied from `CHANGELOG.md`. There is no npm publish step in 0.x. See
10
+ §10 (Future: npm publish) for the criteria that would flip that on.
11
+
12
+ **Current cadence.** 0.x is "tag when ready" — there is no fixed
13
+ calendar release. Anyone with push rights may propose a release by
14
+ opening a PR that bumps versions and updates `CHANGELOG.md`; the
15
+ maintainer team cuts the tag.
16
+
17
+ Cross-reference: `README.md` (Roadmap), `progress.md` / `tickets.md`
18
+ (release content), `docs/deployment-cf.md` (prod deploy step §6),
19
+ `.github/workflows/ci.yml`, `.github/workflows/deploy-cf.yml`.
20
+
21
+ ---
22
+
23
+ ## 1. Versioning
24
+
25
+ ServerlessIRCd follows [Semantic Versioning](https://semver.org/) with
26
+ the 0.x convention:
27
+
28
+ | Bump | When |
29
+ |-------------|-----------------------------------------------------------------|
30
+ | `0.x.0 → 0.(x+1).0` | Any user-visible behavior change, new command/cap, new adapter, schema change to persisted DO state. |
31
+ | `0.x.y → 0.x.(y+1)`* | Internal-only fixes: tests, docs, refactors that don't change runtime behavior, perf work with identical output. (*Reserved for after 1.0; in 0.x every change is allowed to be a minor.) |
32
+ | `1.0.0` | The Cloudflare **and** AWS adapters are deployed to staging, the parametrized contract suite passes on both, ≥3 reference clients connect cleanly (PLAN §9 exit criteria). |
33
+
34
+ **The 0.x disclaimer.** Per SemVer 0.x, anything may change at any
35
+ minor bump. Consumers of `@serverless-ircd/*` packages (today: only
36
+ this repo itself) should pin exact versions.
37
+
38
+ ### 1.1 Workspace version lock
39
+
40
+ All eight `package.json` files (root + 7 workspace packages) carry the
41
+ **same** version string. They are bumped together in the release
42
+ commit. The pre-release checklist in §4 enforces this.
43
+
44
+ Rationale: even though the packages are private today, keeping a single
45
+ version simplifies future npm publishing, release notes, and
46
+ bug-report triage ("what version are you on?" has one answer).
47
+
48
+ ### 1.2 Persisted-state schema
49
+
50
+ A change to `PERSISTED_STATE_VERSION` in
51
+ `packages/cf-adapter/src/serialize.ts` (or any equivalent versioned
52
+ blob in a future adapter) is a **minor bump minimum**, and requires a
53
+ migration entry in `CHANGELOG.md` under a **⚠️ Migration required**
54
+ heading. See §9 (Rollback) for why rollback across such a bump is
55
+ not always possible.
56
+
57
+ ---
58
+
59
+ ## 2. Roles & permissions
60
+
61
+ | Role | Who has it today | Required for |
62
+ |-----------------------------|--------------------------|---------------------------------------|
63
+ | Push to `main` | Maintainers | Merging the release PR. |
64
+ | Push tags (`v*`) | Maintainers | `git push origin v0.x.0`. |
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. |
68
+
69
+ There is no separate "release engineer" role. The maintainer who cuts
70
+ the tag owns the release end-to-end, including the production deploy
71
+ and the post-release smoke check.
72
+
73
+ ---
74
+
75
+ ## 3. Branch & tag conventions
76
+
77
+ - **Releases are cut from `main`.** No release branches in 0.x.
78
+ - **Tags** are annotated, signed where possible, formatted `v<semver>`
79
+ (e.g. `v0.1.0`, `v0.1.1`, `v1.0.0-rc.1`).
80
+ - **Pre-release tags** use SemVer suffixes: `v0.2.0-rc.1`,
81
+ `v1.0.0-beta.3`. GitHub marks the release as a pre-release
82
+ automatically based on the suffix.
83
+ - The release commit message is `chore(release): vX.Y.Z` (matches the
84
+ tag).
85
+
86
+ ---
87
+
88
+ ## 4. Pre-release checklist
89
+
90
+ Run every box before tagging. Most are verifiable from the repo root.
91
+
92
+ ```bash
93
+ # 0. Sanity: clean tree on main, up to date with origin.
94
+ git status # working tree clean
95
+ git checkout main
96
+ git pull --ff-only origin main
97
+
98
+ # 1. All desired work merged. No pending tickets targeted for this release.
99
+ # Cross-check progress.md — every ticket listed under this release is ✅.
100
+
101
+ # 2. CI is green on the tip of main.
102
+ # Open: https://github.com/<owner>/<repo>/actions
103
+ # - CI workflow: lint / typecheck / coverage all green.
104
+ # - deploy-cf workflow: staging deploy + smoke e2e green.
105
+
106
+ # 3. Local full verify.
107
+ pnpm install --frozen-lockfile
108
+ pnpm lint # biome check .
109
+ pnpm typecheck # tsc --noEmit across workspace
110
+ pnpm test # full suite
111
+ pnpm build # every package produces dist/
112
+
113
+ # 4. Coverage gate (enforced in CI per package).
114
+ # The per-package coverage gate (TICKET-048) runs in CI via
115
+ # `pnpm coverage`; Vitest fails the build on any threshold regression.
116
+ # Thresholds: 100% (lines/functions/branches/statements) on
117
+ # irc-core / irc-server / in-memory-runtime, and ≥90% on every
118
+ # other package (cf-adapter / local-cli / tcp-ws-forwarder). See the
119
+ # "Coverage gate" step in .github/workflows/ci.yml. CI must already
120
+ # be green (step 2), so locally re-run `pnpm coverage` as a
121
+ # belt-and-braces check before tagging.
122
+
123
+ # 5. Staging smoke e2e passes against the deployed tip-of-main.
124
+ node apps/cf-worker/scripts/smoke.mjs \
125
+ --url wss://serverless-ircd-staging.<subdomain>.workers.dev
126
+
127
+ # 6. CHANGELOG.md is complete and accurate (§5).
128
+ # 7. All package.json versions are bumped and lock-step (§6).
129
+ # 8. The Release PR is merged (§7).
130
+ ```
131
+
132
+ If any box fails, **stop**. Fix forward on `main` and restart the
133
+ checklist — do not tag over a broken tip.
134
+
135
+ ### 4.1 What is explicitly out of scope for a 0.x release
136
+
137
+ - AWS adapter (TICKET-039 through TICKET-044).
138
+ - SASL authentication (TICKET-026).
139
+ - Flood control (TICKET-028).
140
+ - Server password / rate limits / cloaking (TICKET-046).
141
+ - Config loader (TICKET-047).
142
+
143
+ These belong in a later 0.x release. Their absence is **not** a
144
+ blocker and should be called out under "Known limitations" in the
145
+ GitHub Release body.
146
+
147
+ ---
148
+
149
+ ## 5. Writing `CHANGELOG.md`
150
+
151
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), newest
152
+ entry first. One `## [X.Y.Z] - YYYY-MM-DD` section per release.
153
+
154
+ Categories (use only what applies):
155
+
156
+ - **Added** — new features, commands, caps, adapters.
157
+ - **Changed** — behavior changes in existing functionality.
158
+ - **⚠️ Migration required** — anything that needs operator action on
159
+ upgrade (DO schema bumps, persisted-state version bumps, config
160
+ shape changes). One bullet per action item.
161
+ - **Deprecated** — functionality scheduled for removal.
162
+ - **Removed** — functionality removed in this release.
163
+ - **Fixed** — bug fixes.
164
+ - **Security** — vulnerability fixes (also request a CVE).
165
+ - **Known limitations** — explicit "not in this release" list so
166
+ operators aren't surprised.
167
+
168
+ Source material for the entry:
169
+
170
+ 1. `progress.md` — the ticket table. Every `✅` row since the previous
171
+ release tag is a candidate bullet.
172
+ 2. `tickets.md` — the per-ticket **Description** field, for the prose.
173
+ 3. `git log v<previous>..HEAD --oneline` — for fixes that don't have a
174
+ ticket.
175
+
176
+ Group by user-facing theme (Registration, Channel ops, IRCv3,
177
+ Cloudflare adapter, …) rather than by ticket number. Operators read
178
+ themes; developers cross-reference tickets.
179
+
180
+ Rule of thumb: a new operator reading only `CHANGELOG.md` should
181
+ understand what changed and what they need to do.
182
+
183
+ ---
184
+
185
+ ## 6. Bumping versions
186
+
187
+ There are eight `package.json` files. They must end up at the same
188
+ version:
189
+
190
+ ```
191
+ ./package.json (workspace root)
192
+ packages/irc-core/package.json
193
+ packages/irc-server/package.json
194
+ packages/in-memory-runtime/package.json
195
+ packages/cf-adapter/package.json
196
+ apps/local-cli/package.json
197
+ apps/cf-worker/package.json
198
+ tools/tcp-ws-forwarder/package.json
199
+ ```
200
+
201
+ To cut `v0.X.Y`:
202
+
203
+ ```bash
204
+ # from the repo root, on a fresh branch off main:
205
+ git checkout -b release/v0.X.Y
206
+
207
+ # bump each file. The version field is the only change.
208
+ # (Optional helper: `pnpm -r exec -- node -e '...'` if you script it.)
209
+
210
+ # verify all eight files agree:
211
+ rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json
212
+ ```
213
+
214
+ The lockfile (`pnpm-lock.yaml`) is unaffected — none of the workspace
215
+ dependencies are version-pinned to the workspace root.
216
+
217
+ Do **not** run `pnpm publish` or `npm version` — those are for npm
218
+ distribution, which is not the current model.
219
+
220
+ ---
221
+
222
+ ## 7. The release PR
223
+
224
+ The release PR contains **exactly two files changed**:
225
+
226
+ 1. `CHANGELOG.md` (new entry under the version being cut).
227
+ 2. Every `package.json` listed in §6 (version bump).
228
+
229
+ Nothing else. If a code change is needed for the release, it lands in
230
+ a separate PR first and the release PR is opened only after that PR
231
+ merges.
232
+
233
+ **PR title:** `chore(release): v0.X.Y`
234
+ **PR body:**
235
+
236
+ ```
237
+ This cuts v0.X.Y. See CHANGELOG.md for the full diff vs. v0.W.Z.
238
+
239
+ Pre-release checklist: docs/release.md §4 — all green.
240
+ Out-of-scope tickets: docs/release.md §4.1.
241
+
242
+ After merge: tag + GitHub Release + manual prod deploy per
243
+ docs/release.md §8.
244
+ ```
245
+
246
+ The PR is squash-merged with the same title as the commit message.
247
+
248
+ ---
249
+
250
+ ## 8. Cutting the release
251
+
252
+ Run **immediately** after the release PR merges, so the tag points at
253
+ the exact commit the changelog describes.
254
+
255
+ ```bash
256
+ git checkout main
257
+ git pull --ff-only origin main
258
+
259
+ # Confirm the tip commit is the squashed release PR.
260
+ git log -1 --oneline # expect: chore(release): v0.X.Y
261
+
262
+ # Confirm versions are at the new release.
263
+ rg '"version":' package.json packages/*/package.json apps/*/package.json tools/*/package.json
264
+ ```
265
+
266
+ ### 8.1 Tag and push
267
+
268
+ ```bash
269
+ git tag -a v0.X.Y -m "v0.X.Y"
270
+ git push origin v0.X.Y
271
+ ```
272
+
273
+ Annotated (`-a`) tags carry tagger + date + message; use them over
274
+ lightweight tags. Sign with `-s` if you have a GPG / SSH signing key
275
+ configured (`git config tag.gpgSign true`).
276
+
277
+ ### 8.2 Create the GitHub Release
278
+
279
+ 1. <https://github.com/<owner>/<repo>/releases/new> → choose the tag
280
+ just pushed.
281
+ 2. **Title:** `v0.X.Y` (matches the tag, nothing fancier).
282
+ 3. **Body:** paste the matching section from `CHANGELOG.md`. Strip the
283
+ `## [X.Y.Z] - YYYY-MM-DD` heading line (it's redundant with the
284
+ release title).
285
+ 4. **Set as latest release** unless this is a pre-release. Pre-release
286
+ tags (`-rc.N`, `-beta.N`, etc.) get the "Set as a pre-release"
287
+ checkbox instead.
288
+ 5. **Publish release.**
289
+
290
+ ### 8.3 Production deploy (manual)
291
+
292
+ There is **no CI auto-deploy to production**. The maintainer runs the
293
+ prod deploy by hand, from a clean checkout on the tag:
294
+
295
+ ```bash
296
+ git checkout v0.X.Y
297
+
298
+ # Sanity-check the tag matches the released commit.
299
+ git log -1 --oneline # expect: chore(release): v0.X.Y
300
+
301
+ pnpm install --frozen-lockfile
302
+ pnpm build
303
+ pnpm typecheck
304
+ pnpm test
305
+
306
+ # Deploy the Cloudflare Worker to production.
307
+ pnpm deploy:cf:prod
308
+ # → wrangler deploy (default env = production)
309
+
310
+ # Tail prod logs while the first connections arrive.
311
+ npx wrangler tail
312
+
313
+ # Smoke-check prod with the same script CI uses on staging.
314
+ # You must point it at the prod wss:// URL:
315
+ node apps/cf-worker/scripts/smoke.mjs \
316
+ --url wss://<prod-workers-subdomain>.workers.dev
317
+ ```
318
+
319
+ If smoke fails on prod:
320
+
321
+ 1. **Do not roll forward blindly.** Tail logs, identify the regression.
322
+ 2. If a rollback is required, follow §9.
323
+ 3. If the regression is in the *deployed Worker code* (not the
324
+ protocol core), a hotfix follows the same process as a normal
325
+ release (new patch / minor → tag → GitHub Release → prod deploy).
326
+
327
+ ### 8.4 Announce
328
+
329
+ - Update `progress.md` Status header (if a "current release" marker
330
+ was added there) — not currently present, optional.
331
+ - Open an issue with the body "v0.X.Y released" if external tracking
332
+ is desired. (Not currently wired to any external channel.)
333
+
334
+ ---
335
+
336
+ ## 9. Rollback
337
+
338
+ There are two independent rollback axes: **code** and **data**.
339
+
340
+ ### 9.1 Code rollback
341
+
342
+ To redeploy a previous release:
343
+
344
+ ```bash
345
+ git checkout v0.W.Z # the previous tag
346
+ pnpm install --frozen-lockfile
347
+ pnpm build
348
+ pnpm deploy:cf:prod
349
+ ```
350
+
351
+ This works as long as the previous release's code is compatible with
352
+ the persisted DO state shape (see 9.2).
353
+
354
+ There is no in-CI "prod rollback" workflow. The maintainer runs the
355
+ commands above from a checkout.
356
+
357
+ ### 9.2 Data rollback (persisted DO state)
358
+
359
+ **Persisted-state version is one-way.** Once
360
+ `packages/cf-adapter/src/serialize.ts` ships a new
361
+ `PERSISTED_STATE_VERSION`, old code reading new blobs will reject
362
+ them, and new code reading old blobs may or may not migrate them
363
+ depending on whether a forward migration was shipped alongside.
364
+
365
+ Rules of thumb:
366
+
367
+ - If the release's `CHANGELOG.md` has **no ⚠️ Migration required**
368
+ entry, code rollback to the previous tag is safe — DO storage is
369
+ forward- and backward-compatible.
370
+ - If it **does** have a migration entry, assume code rollback will
371
+ not work without losing or transforming state. The migration entry
372
+ in `CHANGELOG.md` must spell out the recovery procedure.
373
+
374
+ In a catastrophic prod failure where the new version's migration
375
+ already ran, the path forward is:
376
+
377
+ 1. Freeze new connections (set `MOTD_LINES` to a maintenance banner,
378
+ or block at the edge with a Route rule).
379
+ 2. Decide: fix forward on the new code, or wipe staging-state (NOT
380
+ prod-state) and re-test the migration offline.
381
+ 3. **Production DO namespaces must never be deleted** — the data is
382
+ gone permanently (per `docs/deployment-cf.md` §12.1).
383
+
384
+ ---
385
+
386
+ ## 10. Future: npm publish
387
+
388
+ When `@serverless-ircd/irc-core` becomes the first package consumers
389
+ outside this repo depend on (likely: the in-memory runtime is pulled
390
+ into a third-party test harness, or someone ships an alternate
391
+ adapter), the following gates apply before flipping `"private": false`:
392
+
393
+ 1. **Public API audit.** Every export from `src/index.ts` is one we're
394
+ willing to support across a minor. Anything experimental goes under
395
+ a `/internal` subpath.
396
+ 2. **Stable type surface.** No `any`, no `@ts-expect-error` in the
397
+ public surface, no `exactOptionalPropertyTypes` surprises.
398
+ 3. **README per package**, not just the root.
399
+ 4. **`CHANGELOG.md` is already disciplined** (Keep a Changelog format,
400
+ one section per version, dated).
401
+ 5. **CI coverage gate ≥90%** (TICKET-048) is wired and green.
402
+ 6. **Provenance & provenanced verification** — npm publish from CI
403
+ with Sigstore signatures, not a local laptop.
404
+
405
+ At that point this document grows a §11 "Publishing to npm" with
406
+ `pnpm -r publish --filter @serverless-ircd/irc-core --access public`
407
+ and a new `publish.yml` workflow. The git-tag-plus-GitHub-Release flow
408
+ above stays unchanged — npm publish becomes an *additional* step, not
409
+ a replacement.
410
+
411
+ ---
412
+
413
+ ## 11. Quick reference: cutting v0.X.Y
414
+
415
+ ```bash
416
+ # 1. Pre-release checklist (docs/release.md §4).
417
+ git checkout main && git pull --ff-only origin main
418
+ pnpm install --frozen-lockfile
419
+ pnpm lint && pnpm typecheck && pnpm test && pnpm build
420
+
421
+ # 2. Branch, bump versions + CHANGELOG, open PR (§6, §7).
422
+ git checkout -b release/v0.X.Y
423
+ # ... edit 8 × package.json + CHANGELOG.md ...
424
+ git commit -am "chore(release): v0.X.Y"
425
+ git push -u origin release/v0.X.Y
426
+ # ... open PR, get it merged ...
427
+
428
+ # 3. Tag and push (§8.1).
429
+ git checkout main && git pull --ff-only origin main
430
+ git tag -a v0.X.Y -m "v0.X.Y"
431
+ git push origin v0.X.Y
432
+
433
+ # 4. GitHub Release (§8.2): paste CHANGELOG entry, publish.
434
+
435
+ # 5. Prod deploy (§8.3).
436
+ git checkout v0.X.Y
437
+ pnpm install --frozen-lockfile && pnpm build
438
+ pnpm deploy:cf:prod
439
+ node apps/cf-worker/scripts/smoke.mjs --url wss://<prod>.workers.dev
440
+ ```
441
+
442
+ Total wall-clock for a routine 0.x release: ~30 minutes including the
443
+ prod deploy and smoke check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.5.0"
20
+ "@serverless-ircd/aws-adapter": "0.6.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -194,6 +194,9 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
194
194
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
195
195
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
196
196
  ...(params.history !== undefined ? { history: params.history } : {}),
197
+ // API Gateway terminates TLS before the Lambda is invoked, so every
198
+ // WebSocket connection is secure → user mode `S`.
199
+ secure: true,
197
200
  });
198
201
 
199
202
  try {
@@ -158,6 +158,13 @@ export interface NlbStreamParams {
158
158
  * graceful error-suffixed `382`.
159
159
  */
160
160
  configLoader?: () => Promise<ServerConfig>;
161
+ /**
162
+ * Whether the NLB listener terminates TLS for this flow. Set to `true`
163
+ * when the target group fronts a TLS listener (the `irc+tls://` port) so
164
+ * the connection surfaces user mode `S`; omit / set `false` for a plain
165
+ * TCP listener. The stack code sets this from the listener protocol.
166
+ */
167
+ secure?: boolean;
161
168
  }
162
169
 
163
170
  /**
@@ -253,6 +260,8 @@ export async function handleNlbStream(
253
260
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
254
261
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
255
262
  ...(params.history !== undefined ? { history: params.history } : {}),
263
+ // Surface TLS fact when the NLB listener terminates TLS (irc+tls port).
264
+ ...(params.secure === true ? { secure: true } : {}),
256
265
  });
257
266
 
258
267
  if (lines.length > 0) {
@@ -56,6 +56,13 @@ export interface MarshalledConnection {
56
56
  connectionId: ConnId;
57
57
  registration: RegistrationState;
58
58
  capNegotiating: boolean;
59
+ /**
60
+ * Transport TLS fact (user mode `S`). Optional on the persisted shape so
61
+ * rows written before the field shipped deserialize with a `false`
62
+ * default via the coalescing in {@link unmarshalConnection}. Always
63
+ * written by {@link marshalConnection}.
64
+ */
65
+ secure?: boolean;
59
66
  caps: string[];
60
67
  /**
61
68
  * Present (as a JS `Set`, auto-marshalled to `SS`) only when the
@@ -102,6 +109,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
102
109
  connectionId: state.id,
103
110
  registration: state.registration,
104
111
  capNegotiating: state.capNegotiating,
112
+ secure: state.secure,
105
113
  caps: [...state.caps],
106
114
  userModes: { ...state.userModes },
107
115
  lastSeen: state.lastSeen,
@@ -140,13 +148,20 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
140
148
  id: row.connectionId,
141
149
  registration: row.registration,
142
150
  capNegotiating: row.capNegotiating,
151
+ // `secure` is absent on rows persisted before user mode `S` shipped;
152
+ // default to false (plain transport) so the state shape stays satisfied.
153
+ secure: row.secure ?? false,
143
154
  caps: new Set<string>(row.caps),
144
155
  // `joinedChannels` is absent on rows that never joined a channel
145
156
  // (DynamoDB rejects empty sets); default to an empty Set. The SDK
146
157
  // unmarshalls `SS` back to a native Set, which the Set constructor
147
158
  // copies; arrays (older rows) also iterate fine.
148
159
  joinedChannels: new Set<ChanName>(row.joinedChannels ?? []),
149
- userModes: { ...row.userModes },
160
+ // `userModes.tls` is absent on rows persisted before user mode `S`
161
+ // shipped; default to false so the field is a real boolean rather than
162
+ // `undefined`. Cast to Partial so TypeScript accepts the explicit
163
+ // default without flagging the spread overwrite (TS2783).
164
+ userModes: normalizeUserModes(row.userModes),
150
165
  lastSeen: row.lastSeen,
151
166
  connectedSince: row.connectedSince,
152
167
  };
@@ -162,6 +177,19 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
162
177
  return state;
163
178
  }
164
179
 
180
+ /**
181
+ * Defaults `tls` to false on a deserialized `UserModes`. Rows persisted
182
+ * before user mode `S` shipped lack the field; the DynamoDB SDK round-trip
183
+ * yields `undefined`, which would leak into runtime code expecting a real
184
+ * boolean. Casting to `Partial` lets the explicit default apply without
185
+ * TypeScript flagging the spread overwrite (TS2783).
186
+ */
187
+ function normalizeUserModes(um: UserModes): UserModes {
188
+ const out: Partial<UserModes> = { ...um };
189
+ if (out.tls === undefined) out.tls = false;
190
+ return out as UserModes;
191
+ }
192
+
165
193
  // ---------------------------------------------------------------------------
166
194
  // Nicks
167
195
  // ---------------------------------------------------------------------------
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
- import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
14
15
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
16
  import {
16
17
  loadDynamoAccountStore,
@@ -126,6 +127,48 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
126
127
  false,
127
128
  );
128
129
  });
130
+
131
+ it('silently skips rows with missing or wrong-typed credential fields', async () => {
132
+ // The scan path must tolerate partially-malformed rows (legacy seeds,
133
+ // schema drift) without throwing — it just skips them. Each branch of
134
+ // the per-field `typeof === 'string'` guard is exercised by writing a
135
+ // row where exactly one non-key field has the wrong type. (The
136
+ // `account` field is the partition key, so DynamoDB itself enforces
137
+ // its type at write time and the false branch is not exercisable
138
+ // through the SDK; that branch is a defensive guard against
139
+ // synthetically-shaped Scan results.)
140
+ const client = requireFx().client;
141
+ const table = requireFx().tableName;
142
+ // Well-formed row — must survive.
143
+ await putAccountCredential(client, table, 'good', 's3cret');
144
+ // Row with non-string `algorithm`.
145
+ await client.send(
146
+ new PutCommand({
147
+ TableName: table,
148
+ Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
149
+ }),
150
+ );
151
+ // Row with non-string `salt`.
152
+ await client.send(
153
+ new PutCommand({
154
+ TableName: table,
155
+ Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
156
+ }),
157
+ );
158
+ // Row with non-string `hash`.
159
+ await client.send(
160
+ new PutCommand({
161
+ TableName: table,
162
+ Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
163
+ }),
164
+ );
165
+ const store = await loadDynamoAccountStore(client, table);
166
+ expect(store).toBeDefined();
167
+ expect(store?.size).toBe(1);
168
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
169
+ true,
170
+ );
171
+ });
129
172
  });
130
173
 
131
174
  describe.skipIf(!available)('resolveAccountStore precedence', () => {
@@ -139,10 +182,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
139
182
 
140
183
  it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
141
184
  await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
142
- const cfgWithSasl = {
143
- ...DEFAULT_SERVER_CONFIG,
185
+ const cfgWithSasl = makeTestServerConfig({
144
186
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
145
- };
187
+ });
146
188
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
147
189
  expect(store).toBeDefined();
148
190
  // Table account verifies; config account does NOT (table is authoritative).
@@ -155,10 +197,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
155
197
  });
156
198
 
157
199
  it('falls back to the config-seeded store when the table is empty', async () => {
158
- const cfgWithSasl = {
159
- ...DEFAULT_SERVER_CONFIG,
200
+ const cfgWithSasl = makeTestServerConfig({
160
201
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
161
- };
202
+ });
162
203
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
163
204
  expect(store).toBeDefined();
164
205
  expect(
@@ -170,7 +211,7 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
170
211
  const store = await resolveAccountStore(
171
212
  requireFx().client,
172
213
  requireFx().tableName,
173
- DEFAULT_SERVER_CONFIG,
214
+ makeTestServerConfig(),
174
215
  );
175
216
  expect(store).toBeUndefined();
176
217
  });
@@ -1,9 +1,6 @@
1
1
  import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
2
- import {
3
- DEFAULT_SERVER_CONFIG,
4
- type ParsedServerConfig,
5
- type SaslPayload,
6
- } from '@serverless-ircd/irc-core';
2
+ import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
3
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
7
4
  import { mockClient } from 'aws-sdk-client-mock';
8
5
  import { describe, expect, it } from 'vitest';
9
6
  import {
@@ -21,7 +18,7 @@ function plainPayload(username: string, password: string): SaslPayload {
21
18
 
22
19
  /** Builds a minimal parsed config with the supplied saslAccounts. */
23
20
  function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
24
- return { ...DEFAULT_SERVER_CONFIG, saslAccounts: accounts };
21
+ return makeTestServerConfig({ saslAccounts: accounts });
25
22
  }
26
23
 
27
24
  // ---------------------------------------------------------------------------
@@ -184,7 +181,7 @@ describe('resolveAccountStore', () => {
184
181
  'Accounts',
185
182
  fallback === undefined
186
183
  ? undefined
187
- : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
184
+ : makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
188
185
  );
189
186
  expect(store).toBeDefined();
190
187
  expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);