serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
  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 +267 -92
  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/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
package/CHANGELOG.md CHANGED
@@ -10,6 +10,556 @@ For the release process itself — versioning policy, pre-release checklist,
10
10
  cutting a tag, rolling back — see [`docs/Release-Process.md`](docs/Release-Process.md).
11
11
  Cross-reference `progress.md` / `tickets.md` for per-ticket detail.
12
12
 
13
+ ## [0.10.0] - 2026-08-13
14
+
15
+ The main change in this release is **AWS web client parity** — the
16
+ vendored Kiwi IRC SPA is no longer Cloudflare-only. AWS now hosts it
17
+ via an **S3 + CloudFront + OAC** `StaticSite` construct (opt-in via
18
+ CDK context) with a **stack-output-driven two-phase deploy**, and the
19
+ API Gateway `$connect` route gains an opt-in `WEB_ORIGINS` CSWSH
20
+ defence that mirrors the CF Worker's. Alongside it: the SASL
21
+ `AccountStore` is **collapsed into `ServicesStore`** as the single
22
+ credential home (a one-shot backfill migration ships for existing
23
+ deployments), **NickServ `SET PASSWORD`** lands as self-service
24
+ password change, and the staging/prod deploy split is **collapsed**
25
+ into a single target per platform. A round of **AWS deploy security
26
+ hardening** ships: the CI workflow is **OIDC-only** (no long-lived
27
+ access keys), and API Gateway full-frame body logging
28
+ (`DataTraceEnabled`) is **off by default and hard-locked** so IRC
29
+ frames are never written to CloudWatch. Finally, `UuidIdFactory` now
30
+ sources its random bytes from `globalThis.crypto` (CSPRNG) instead of
31
+ `Math.random`, so generated `msgid` / session ids are cryptographic-
32
+ grade.
33
+
34
+ ### Added — AWS web client (`@serverless-ircd/aws-stack`, `@serverless-ircd/aws-adapter`, `@serverless-ircd/web`)
35
+
36
+ The web client is no longer Cloudflare-only. AWS provisions a
37
+ static-site origin for the SPA while the wss IRC endpoint stays on
38
+ API Gateway — two origins, same `apps/web/dist/` artifact.
39
+
40
+ - **`StaticSite` construct** (`apps/aws-stack/src/static-site.ts`): a
41
+ private S3 origin bucket fronted by a CloudFront distribution with
42
+ **Origin Access Control (OAC)** — direct S3 access is denied by the
43
+ bucket policy; only CloudFront URLs resolve. `defaultRootObject` is
44
+ `index.html` (the project landing page at `/`), and a custom error
45
+ response maps S3 `403`/`404` → `200 /webclient/index.html` so
46
+ client-side SPA routes resolve. Custom domain (ACM certificate +
47
+ Route53 `ARecord` alias) is opt-in via CDK context
48
+ (`webSiteCustomDomain` / `webSiteCertificateArn` /
49
+ `webSiteHostedZoneName` / `webSiteHostedZoneId`); omitted by default
50
+ to use `*.cloudfront.net`. Three stable CfnOutputs (`WebsiteURL`,
51
+ `WebsiteBucketName`, `WebsiteDistributionId`) drive the deploy
52
+ pipeline below.
53
+ - **CloudFront directory-index viewer-request function**: S3 REST
54
+ (used with OAC) does not serve index documents, so `/docs/` or
55
+ `/docs` would 404 and fall through to the SPA error response
56
+ (serving the Kiwi shell instead of the rendered docs index). A
57
+ viewer-request CloudFront function now resolves directory-style
58
+ URIs: trailing-slash paths (`/docs/`, `/`) get `index.html`
59
+ appended as an internal rewrite, and extensionless paths without a
60
+ trailing slash (`/docs`) get a `301` redirect to the trailing-slash
61
+ form so the browser resolves relative asset links against the
62
+ directory. Real assets (`.html`, `.css`, `.js`, …) are served
63
+ unchanged. Mirrors the CF Worker `[assets]` binding, which resolves
64
+ directory indexes automatically.
65
+ - **`$connect` Origin allowlist (CSWSH defence)**: API Gateway
66
+ WebSocket has no built-in `Origin` validation, so the `$connect`
67
+ Lambda now enforces it. `WEB_ORIGINS` (comma-separated) is parsed
68
+ into a normalised lowercase set at cold start; a browser-sent
69
+ `Origin` not in the set is denied with `403` (API Gateway closes
70
+ the upgrade). The defence is **opt-in** — unset `WEB_ORIGINS`
71
+ disables the check entirely so existing bare-IRC deployments without
72
+ a web frontend are unchanged on upgrade. Non-browser clients (curl,
73
+ WeeChat, the `tcp-ws-forwarder`, the NLB TCP+TLS path) never send
74
+ `Origin` and always proceed. Deliberate divergence from the CF
75
+ policy: **explicit-allowlist only** (no same-origin auto-derive),
76
+ because the SPA is on a CloudFront origin and the wss endpoint is
77
+ on an API Gateway origin, so the request's own host is never the
78
+ SPA's origin. Mirrors the CF Worker's parsing semantics (trimmed,
79
+ lowercased, scheme+host match).
80
+ - **`prod-aws` build env**: a new `apps/web/static/config.prod-aws.json`
81
+ + `--env prod-aws` (and a `build:prod-aws` pnpm script) select the
82
+ AWS API-Gateway-shaped config. The `server` field carries
83
+ `{{API_ID}}` / `{{REGION}}` / `{{STAGE}}` placeholders the deploy
84
+ pipeline substitutes at bake time, so no concrete wss URL is
85
+ hardcoded in the repo. Because irc-framework prepends `wss://`
86
+ itself from `tls: true`, the baked URL is **split** into a
87
+ hostname-only `server` and a stage-name `direct_path` (e.g.
88
+ `/<stage>`) — baking the full `wss://` URL into `server` produced
89
+ a doubled-scheme `wss://wss//...` URL that Firefox rejects.
90
+ - **Stack-output-driven deploy pipeline** (`scripts/deploy-web-aws.mjs`,
91
+ a new `@serverless-ircd/deploy-scripts` workspace package): unlike
92
+ the CF Worker (where `wrangler deploy` ships the SPA in the same
93
+ command as the runtime), the AWS SPA deploy is **two-phase** because
94
+ the baked `config.json` carries the API Gateway WebSocket URL as a
95
+ literal that does not exist at synth time. The script runs
96
+ `describe-stacks` → `pnpm --filter web build:prod-aws -- --api-url
97
+ <ConnectUrl>` → `aws s3 sync --delete` →
98
+ `cloudfront create-invalidation /*`. Fails loudly on missing stack
99
+ outputs or unsubstituted `{{...}}` placeholders. The
100
+ `deploy-web` CI job (`.github/workflows/deploy-aws.yml`) runs it
101
+ with `needs: deploy`.
102
+
103
+ ### Added — NickServ `SET PASSWORD` (`@serverless-ircd/irc-core`)
104
+
105
+ - **Self-service password change.** `PRIVMSG NickServ :SET PASSWORD
106
+ <old> <new>` re-verifies the current password (defence against a
107
+ hijacked `+r` session), enforces a configurable minimum (default 8)
108
+ and a fixed maximum (256, the scrypt input budget), then re-hashes
109
+ and persists via `ServicesStore.setNickPassword`. The caller's
110
+ session stays `+r`; other sessions on the same account are not
111
+ kicked. Wrong old password is indistinguishable from "no such
112
+ account" (no enumeration vector). Wired through
113
+ `InMemoryServicesStore` and `PersistentServicesStore` with round-
114
+ trip tests on the D1 and DynamoDB backends.
115
+
116
+ ### Added — CSPRNG-backed ids (`@serverless-ircd/irc-core`)
117
+
118
+ - **`UuidIdFactory` now uses `globalThis.crypto.getRandomValues`.** The
119
+ previous `Math.random`-based UUIDv4 generation was unsuitable for
120
+ unforgeable `msgid` / session ids — a predictable id would let a
121
+ client forge message attribution. The global `crypto.getRandomValues`
122
+ is available on Node ≥ 19 and Cloudflare Workers without an import.
123
+ Generated ids are now cryptographic-grade. The `IdFactory` port and
124
+ the deterministic `testIdFactory` test seam are unchanged, so
125
+ reducer determinism (reducers never touch real randomness) is
126
+ preserved; only the production factory changed.
127
+
128
+ ### Changed — Single credential home (`@serverless-ircd/irc-core`, `@serverless-ircd/cf-adapter`, `@serverless-ircd/aws-adapter`)
129
+
130
+ The SASL `AccountStore` port is **removed**. `ServicesStore` is now
131
+ the single credential home: SASL PLAIN, SASL EXTERNAL (CertFP),
132
+ NickServ `IDENTIFY`, and `PASS <nick>:<password>` all verify through
133
+ the same scrypt-hashed `verifyNick` / `verifyCertFP` surface. This
134
+ closes the cross-store credential drift flagged as a known limitation
135
+ in v0.9.0 — a nick registered via NickServ `REGISTER` after the worker
136
+ booted now authenticates via SASL/PASS immediately, because there is
137
+ no second store to drift out of sync with.
138
+
139
+ - **`ServicesStore` grows CertFP**: `verifyCertFP` / `addCertFP` /
140
+ `removeCertFP` are added to the port, and `RegisteredNick` gains a
141
+ `certSubjects: string[]` field so SASL EXTERNAL resolves through the
142
+ services store (previously SASL EXTERNAL only worked against the
143
+ static `InMemoryAccountStore`, never the hashed D1/DynamoDB table).
144
+ - **D1 `cert_subjects` column auto-migrates**: the CF D1 path runs
145
+ `ALTER TABLE nickserv_accounts ADD COLUMN cert_subjects` inside
146
+ `migrateServicesSchema` on cold start (`CREATE TABLE IF NOT EXISTS`
147
+ + the `ALTER`), so no manual DDL is needed. DynamoDB is schemaless.
148
+ - **Removed**: `AccountStore` port, `InMemoryAccountStore`,
149
+ `HashedAccountStore`, the D1/DynamoDB `account-store`
150
+ implementations, `SaslPayload` / `SaslResult` / `SaslAccountCredential`
151
+ / `constantTimeEquals` / `ingestAccountCredential`, and the
152
+ `accounts` / `Accounts` table reads. The new code only reads
153
+ `nickserv_accounts` / `Services`.
154
+ - **`tools/migrate-accounts-to-services.ts`**: a one-shot, idempotent,
155
+ never-overwrite backfill of legacy `accounts` / `Accounts` rows into
156
+ `nickserv_accounts` / `Services`. Reads the still-deployed
157
+ `SASL_ACCOUNTS` env var at run time so env-seeded accounts are
158
+ carried into the services table. `cert_subjects` defaults to `'[]'`
159
+ for every migrated row (the legacy table had no cert column).
160
+ **Run this before the v0.10.0 build deploys** — see the migration
161
+ section below.
162
+
163
+ ### Changed — Single deploy target per platform
164
+
165
+ The staging/prod deploy split is **collapsed** into one target per
166
+ platform. Staging vs production isolation is now driven by which
167
+ account/region (AWS) or which Worker credentials (CF) the deploy
168
+ targets, not by stack/table/Worker name templating.
169
+
170
+ - **AWS CDK**: drops the `environmentName` prop, `prefixedTableName`,
171
+ and the `IrcAwsStack-<env>` stack id. Tables use bare logical ids
172
+ (`Connections`, `Nicks`, …); the stack is always `IrcAwsStack`.
173
+ - **Cloudflare**: drops the `env.staging` blocks in the cf-worker and
174
+ cf-tcp-container wrangler configs.
175
+ - **Web build**: drops `build:staging` / `config.staging.json`; `--env`
176
+ now accepts only `prod` (or the default dev config) and `prod-aws`.
177
+ - **CI**: collapses `deploy-*: staging` jobs into single `deploy` /
178
+ `deploy-web` jobs; script names align (`pnpm deploy:cf`,
179
+ `pnpm deploy:aws`, `pnpm deploy:cf:tcp`, `pnpm smoke:aws`,
180
+ `pnpm smoke:cf`). Deploy workflows remain `workflow_dispatch`-only
181
+ (no push trigger).
182
+
183
+ ### Added — AWS deploy security
184
+
185
+ - **OIDC-only CI deploys.** The GitHub Actions deploy workflow
186
+ (`deploy-aws.yml`) accepts **only** GitHub OIDC web-identity
187
+ credentials. `aws-access-key-id` / `aws-secret-access-key` inputs
188
+ are dropped from every `configure-aws-credentials` step in both the
189
+ `deploy` and `deploy-web` jobs; the workflow requires
190
+ `AWS_DEPLOY_ROLE_ARN` and fails fast with `::error::` if it is
191
+ unset, then asserts `aws sts get-caller-identity` matches the
192
+ configured role before any `cdk deploy` (defence-in-depth against a
193
+ silent fallback to ambient static credentials). Long-lived access
194
+ keys powerful enough to deploy CloudFormation + IAM + Lambda +
195
+ DynamoDB are effectively account-admin; a single exfil vector
196
+ yields full account takeover. The full IAM trust policy snippet,
197
+ condition keys, and verification steps are in
198
+ `docs/AWS-Deployment.md` §17. A `ci-hardening` lint + test suite
199
+ (`findAwsCredentialSteps`, `readWorkflowPermissions`) locks the
200
+ OIDC-only contract on every PR.
201
+ - **API Gateway `DataTraceEnabled` off by default and hard-locked.**
202
+ Full-frame body tracing wrote every IRC frame (`PASS <password>`,
203
+ `AUTHENTICATE <base64-SASL-PLAIN>`, `JOIN #chan <key>`,
204
+ `PRIVMSG`/`NOTICE`) to the APIGW execution-log group, leaking
205
+ credentials to anyone with `logs:GetLogEvents`. Now safe-by-default
206
+ (`false`); because the staging/prod split collapsed, there is no
207
+ quiet "non-prod" path to re-enable it. The only way back on is an
208
+ explicit **two-step opt-in** (`-c allowDataTrace=true` **and** `-c
209
+ iUnderstandThisLeaksCredentials=true`) that refuses synthesis unless
210
+ both flags are set — see `docs/AWS-Deployment.md` §7.6. Tear the
211
+ sandbox stack down immediately after debugging; never deploy that
212
+ combination to a shared account.
213
+
214
+ ### Changed — Documentation
215
+
216
+ - **`README.md` updated** to cover AWS web client hosting (the web
217
+ client section was previously Cloudflare-only), the AWS `$connect`
218
+ CSWSH defence, the OIDC-only deploy model, the `DataTraceEnabled`
219
+ hard-lock, and the `scripts/deploy-web-aws.mjs` two-phase deploy.
220
+ The CF-vs-AWS origin difference is laid out in a table, and the
221
+ CSWSH section now documents both adapters' modes (same-origin
222
+ auto-derive on CF; explicit-allowlist-only on AWS).
223
+
224
+ ### ⚠️ Migration required
225
+
226
+ - **Run `tools/migrate-accounts-to-services.ts` BEFORE deploying
227
+ v0.10.0** if the deployment has any rows in the legacy `accounts`
228
+ (CF D1) / `Accounts` (AWS DynamoDB) table. The v0.10.0 build only
229
+ reads `nickserv_accounts` / `Services`, so legacy rows that are not
230
+ backfilled will stop authenticating. The script is one-shot,
231
+ idempotent (`INSERT OR IGNORE` on D1 / conditional `PutItem` on
232
+ DynamoDB), and never-overwrites a nick already registered in the
233
+ services table. It reads the still-deployed `SASL_ACCOUNTS` env var
234
+ at run time so env-seeded accounts are carried through. See the
235
+ script header for the per-platform invocation:
236
+
237
+ ```bash
238
+ # Cloudflare D1
239
+ node --import tsx tools/migrate-accounts-to-services.ts \
240
+ --platform cf --database <d1-name> --remote
241
+
242
+ # AWS DynamoDB
243
+ node --import tsx tools/migrate-accounts-to-services.ts \
244
+ --platform aws --accounts-table Accounts --services-table Services \
245
+ --region us-east-1
246
+ ```
247
+
248
+ - **Single deploy target: rename your deploy commands.** The
249
+ staging-specific commands are gone. Update any scripts, runbooks, or
250
+ CI that called `pnpm deploy:cf:staging` / `pnpm deploy:aws:staging`
251
+ / `--env staging` to the single-target form (`pnpm deploy:cf`,
252
+ `pnpm deploy:aws`). Staging vs production isolation is now by
253
+ account/region (AWS) or by Worker credentials (CF), not by command
254
+ suffix.
255
+ - **AWS CI: switch to OIDC.** If the repo still has
256
+ `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets, delete them
257
+ and invalidate the underlying IAM access key. Configure
258
+ `AWS_DEPLOY_ROLE_ARN` (a GitHub-OIDC-trusted role) per
259
+ `docs/AWS-Deployment.md` §17; there is no static-key fallback.
260
+ - **No persisted-DO-state schema change.** `PERSISTED_STATE_VERSION`
261
+ stays `1`. The `cert_subjects` D1 column is added by an idempotent
262
+ `ALTER TABLE` on cold start; the D1/DynamoDB services-row shape
263
+ change is backward-compatible for reads. **Code rollback across
264
+ this release is safe** provided the account-migration backfill was
265
+ run (rolling back the code re-introduces the legacy `accounts`/
266
+ `Accounts` reads, which still work as long as the table still
267
+ exists — it is not auto-dropped).
268
+
269
+ ### Known limitations
270
+
271
+ - **The web client remains partial.** The Playwright headless-browser
272
+ e2e against a deployed frontend is still pending; the SPA is
273
+ exercised via the WS smoke (`scripts/smoke.mjs`) and the `apps/web`
274
+ unit / build-smoke suite.
275
+ - **The AWS web client `StaticSite` requires a custom domain to
276
+ provision via `bin/aws.ts`.** Provisioning the construct without a
277
+ custom domain (default `*.cloudfront.net` only) currently requires
278
+ editing `bin/aws.ts` to pass `webSite: {}` directly. Tracked as a
279
+ follow-up.
280
+ - Same transport limits as v0.9.0: the **CF TCP path requires
281
+ Cloudflare Spectrum (Enterprise tier)** plus a stateful Container
282
+ origin; the **AWS TCP path** uses NLB + Lambda streaming and is
283
+ subject to Lambda idle-timeout / stream-duration limits. The
284
+ WebSocket path remains the zero-extra-deps default.
285
+ - **mTLS (for SASL `EXTERNAL`) requires a custom domain + uploaded
286
+ CA/trust-store** on both platforms and is not enabled by default.
287
+ The `cert_subjects` plumbing lands the server-side storage; the
288
+ adapter-side client-cert capture at the TLS edge remains a
289
+ follow-up.
290
+ - **Client compatibility sweep** (WeeChat / HexChat / IRCCloud /
291
+ TheLounge / matrix-IRC bridge) remains pending.
292
+ - **Coverage hardening is partial.** `irc-core`, `irc-server`,
293
+ `in-memory-runtime`, and **`cf-adapter`** (new this release) sit at
294
+ 100%; `aws-adapter` and `aws-stack` clear the 90% gate. The
295
+ remaining packages (`local-cli`, `load-test`, `cf-tcp-container`,
296
+ `tcp-ws-forwarder`, `irc-test-support`, `web`) are **above the gate
297
+ but below 100%** — follow-ups drive each to full coverage.
298
+
299
+ ### Security
300
+
301
+ - **OIDC-only AWS deploys** (above): removes long-lived access keys
302
+ from the deploy path — the highest-impact exfil vector against an
303
+ AWS account with deploy permissions.
304
+ - **APIGW `DataTraceEnabled` hard-locked off** (above): IRC frames
305
+ (including `PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys) are
306
+ no longer written to CloudWatch even if an operator toggles the
307
+ console setting by hand; the two-flag escape hatch cannot be set by
308
+ accident.
309
+ - **CSPRNG-backed `msgid` / session ids** (above): generated ids are
310
+ now cryptographic-grade, closing a forgery vector in the previous
311
+ `Math.random`-based `UuidIdFactory`.
312
+ - No other auth path changed. The scrypt-hashed credential store,
313
+ server-password gate, and CF-side CSWSH defence are unchanged from
314
+ v0.9.0.
315
+
316
+ ---
317
+
318
+ ## [0.9.0] - 2026-08-10
319
+
320
+ The main change in this release is **rounding out the integrated IRC
321
+ services surface** that landed in v0.8.0: ChanServ gains the prefix and
322
+ roster mutation verbs operators expect (`OP` / `DEOP` / `VOICE` /
323
+ `DEVOICE` / `KICK` / `BAN` / `UNBAN`), HostServ **auto-applies an
324
+ assigned vhost on identify** (no per-session `HostServ ON`), HostServ
325
+ HELP hides oper-only commands from non-opers, NickServ grows an `ID`
326
+ alias for `IDENTIFY`, and **NickServ `INFO` no longer leaks the
327
+ registrant's email** to non-owners. Alongside it: service **shortcut
328
+ verbs** (`/NICKSERV`, `/NS`, `/CS`, `/HS`, `/MS`, `/OS`, …) that the
329
+ daemon rewrites to the equivalent `PRIVMSG <Service>`, `echo-message`
330
+ reflection of `PRIVMSG` to service nicks, and the long-missing **JOIN
331
+ `332`/`333` topic numerics** on reconnect/rejoin (regardless of how the
332
+ topic came to be set). A real PASS-login bug is fixed: a client that
333
+ completes registration before sending `PASS <nick>:<password>` is now
334
+ logged in instead of receiving a silent no-op. The AWS adapter gets
335
+ another round of consistency hardening — every `Connections` `GetCommand`
336
+ now uses `ConsistentRead: true`. The web client renders the `docs/` wiki
337
+ to standalone HTML at `/docs/` and gains a client-specific "how to
338
+ connect" section on the landing page.
339
+
340
+ ### Added — Services surface expansion (`@serverless-ircd/irc-core`)
341
+
342
+ - **ChanServ `OP` / `DEOP` / `VOICE` / `DEVOICE` / `KICK` / `BAN` /
343
+ `UNBAN`**: founder-or-`AUTOOP`-level callers can grant/remove prefix
344
+ modes on demand, kick through ChanServ, and manage channel bans. Each
345
+ verb emits the corresponding `:ChanServ!ChanServ@services MODE` /
346
+ `KICK` broadcast and an `ApplyChannelDelta` against the channel
347
+ authority (the same mechanism MLOCK reasserts use), so the roster
348
+ and ban list update atomically with the broadcast. A target not on
349
+ the channel produces a NOTICE and no state change. Privilege gating
350
+ reuses the existing access-list check — no new store methods.
351
+ - **HostServ auto-applies an assigned vhost on identify.** SASL
352
+ PLAIN/EXTERNAL, NickServ `IDENTIFY`, and PASS-auth now consult
353
+ `services.getVhost(account)` on a successful login: when an assigned
354
+ vhost exists, `applyVhost` runs immediately — `state.host` is set,
355
+ `state.vhostActive` is stamped, and any currently-joined
356
+ `chghost`-capable peers see the `CHGHOST` broadcast. At SASL/PASS-auth
357
+ time (pre-JOIN) there are no peers so no broadcast, but the vhost is
358
+ visible in the subsequent `001` welcome. Users no longer need to run
359
+ `HostServ ON` every session.
360
+ - **NickServ `ID` alias for `IDENTIFY`.** `PRIVMSG NickServ :ID <pw>`
361
+ and `ID <nick> <pw>` behave identically to `IDENTIFY` — the alias
362
+ is not advertised in HELP (mirrors Atheme's hidden shorthand).
363
+ - **Service shortcut verbs** (`@serverless-ircd/irc-server`):
364
+ `/NICKSERV`, `/NS`, `/CS`, `/HS`, `/MS`, `/OS` (and their long forms)
365
+ are rewritten at the actor to the equivalent
366
+ `PRIVMSG <ServiceNick> :<args>`. Clients with a dedicated
367
+ slash-command UX (WeeChat, HexChat, IRCCloud) now work without a
368
+ custom alias file. The aliases are case-insensitive and accept both
369
+ colon-trailing and space-joined arg shapes.
370
+ - **`echo-message` reflection for service PRIVMSG**: a client that
371
+ has negotiated `echo-message` now sees its own
372
+ `PRIVMSG NickServ :IDENTIFY hunter2` reflected back before the
373
+ NickServ NOTICE reply, matching the echo behaviour for every other
374
+ PRIVMSG target. The echo is emitted before the reply so labeled-
375
+ response batches wrap both correctly.
376
+
377
+ ### Added — Web client (`apps/web`)
378
+
379
+ - **`docs/` wiki rendered to `/docs/`.** The Gitea-wiki markdown under
380
+ the `docs/` submodule is now rendered to standalone HTML at
381
+ `apps/web/dist/docs/<slug>.html` and served by the existing
382
+ `[assets]` binding at `/docs/<slug>.html` (with `Home.md` →
383
+ `/docs/index.html`). Intra-docs Markdown links are rewritten to
384
+ `.html`, a shared stylesheet is emitted, and an `ADR-Index` page
385
+ links every ADR. Requires `git submodule update --init docs` before
386
+ build; the build fails loudly with the recovery command if the
387
+ submodule is missing or empty.
388
+ - **Landing-page "how to connect" section.** The static landing page
389
+ gains a CSS-only tabbed picker with copy-paste connect snippets for
390
+ the common stock IRC clients (WeeChat, HexChat, irssi, IRCCloud),
391
+ replacing the previous per-client static guides. The picker covers
392
+ both the WebSocket path and the `irc+tls :6697` path.
393
+
394
+ ### Changed — PASS-based account login (`@serverless-ircd/irc-core`)
395
+
396
+ - **A late `PASS <nick>:<password>` now logs the connection in.**
397
+ Previously, a client that completed registration (`NICK` + `USER`
398
+ resolving before `PASS` arrived, or any `PASS` sent after `CAP END`
399
+ resolved) was silently rejected — the `passReducer` short-circuited
400
+ at `state.registration === 'registered'` and emitted `462
401
+ ERR_ALREADYREGISTRED` for any post-registration `PASS`. The shared
402
+ verify + `applyAccountSuccess` pipeline now runs on a
403
+ post-registration `PASS <nick>:<password>`: on success it emits
404
+ `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, stamps `+r`, and runs
405
+ the read-marker / away / memo replay in-band, exactly like the
406
+ at-registration path. A bare `PASS <value>` (no colon) is a silent
407
+ no-op once registration has completed — a server password is
408
+ meaningless post-registration, and emitting `462` for it was
409
+ counterproductive. The pre-registration duplicate-PASS `462` (two
410
+ `PASS`es before `NICK`+`USER`) is preserved unchanged.
411
+
412
+ ### Fixed — Channel correctness (`@serverless-ircd/irc-core`)
413
+
414
+ - **JOIN now emits `332 RPL_TOPIC` / `333 RPL_TOPICWHOISTIME` from any
415
+ source of the topic.** The previous JOIN reducer keyed topic
416
+ emission solely on the ChanServ KEEPTOPIC restore path (an empty
417
+ channel this join), so reconnecting clients after a deploy saw the
418
+ NAMES list but no topic — even when the topic survived the deploy
419
+ in storage. The emission now keys off `state.topic`, so a topic
420
+ restored from a KEEPTOPIC snapshot, a deploy snapshot with
421
+ KEEPTOPIC off, or an earlier joiner all produce the correct
422
+ numerics. The KEEPTOPIC restore path still maintains `state.topic`
423
+ for an empty channel; only the emission trigger changed.
424
+ - **No auto-op for the first joiner of a registered channel off the
425
+ access list.** A registered channel that had been emptied (so its
426
+ roster snapshot was fresh) used to auto-op the first joiner
427
+ unconditionally as a "founder fallback", which let any client claim
428
+ `+o` by joining an empty registered channel. The fallback now only
429
+ fires when the joiner is identified as the founder (or is on the
430
+ access list); an unidentified first joiner joins without prefix
431
+ modes.
432
+
433
+ ### Fixed — AWS adapter (`@serverless-ircd/aws-adapter`)
434
+
435
+ - **Strongly-consistent `Connections` reads across every handler.**
436
+ `$disconnect`'s `cleanupConnection` and the NLB streaming handler's
437
+ per-chunk row read were eventually consistent, so under DynamoDB
438
+ replication lag they could miss the row that was just written (or
439
+ updated) — dropping the first frame of a raw `:6697` connection or
440
+ skipping nick release + membership fanout on `$disconnect`. Every
441
+ `GetCommand` against the `Connections` table in `aws-adapter/src/**`
442
+ now sets `ConsistentRead: true`, matching the `$default` fix from
443
+ v0.8.0. A unit test asserts each call site carries the flag.
444
+ - **Bounded `docker info` probe in the test global-setup.** On hosts
445
+ with Docker Desktop installed but not running, the macOS `docker`
446
+ CLI hangs indefinitely waiting for the daemon to spin up — and
447
+ `testcontainers` inherits that hang, stalling the whole `pnpm test`
448
+ run. The aws-adapter `globalSetup` now pre-probes `docker info`
449
+ with a 5-second exec timeout; if Docker is not reachable, it logs
450
+ and falls through to the Java ZIP fallback (or skip) instead of
451
+ hanging. No behaviour change when Docker is up.
452
+
453
+ ### Fixed — HostServ help leak (`@serverless-ircd/irc-core`)
454
+
455
+ - **HostServ HELP hides oper-only commands from non-opers.** The
456
+ help/unknown NOTICE previously listed all seven HostServ commands
457
+ (`ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST`) to every caller,
458
+ leaking the existence and names of oper commands. The notice
459
+ helpers now branch on `state.userModes.oper`: a non-oper sees only
460
+ `ON, OFF, REQUEST`; an oper sees the full list. The per-handler
461
+ oper gates are unchanged, so a non-oper who guesses an oper
462
+ command name still gets the existing permission-denied NOTICE.
463
+
464
+ ### Fixed — NickServ INFO privacy (`@serverless-ircd/irc-core`)
465
+
466
+ - **NickServ `INFO` no longer leaks the registrant's email to
467
+ non-owners.** `handleInfo` previously returned the `Email:` line
468
+ unconditionally to any caller, identified or not — a remote,
469
+ unidentified user could enumerate registered nicks and harvest
470
+ email addresses. The `Email:` line is now gated behind
471
+ `state.account === rec.account` (the owner) or
472
+ `state.userModes.oper === true` (an oper). The owner path still
473
+ shows the full record; `INFO` with no target defaults to the
474
+ caller's own nick (owner path). The `Nick:` / `Account:` lines
475
+ remain visible to all callers (consistent with how a WHOIS against
476
+ a registered nick surfaces the account name).
477
+
478
+ ### Changed — Local CLI (`apps/local-cli`)
479
+
480
+ - **`operCreds` plumbed through the config schema.** The local CLI's
481
+ `ServerConfig` now carries `operCreds` end-to-end (was wired ad-hoc
482
+ through constructor options); OPER auth against configured creds
483
+ works the same way it does in the cloud adapters. The transport
484
+ seams (WS / TCP) are also extracted into named helpers for
485
+ readability.
486
+
487
+ ### Changed — CI / tooling
488
+
489
+ - CI workflows now explicitly checkout every submodule (`docs/`,
490
+ `apps/web/upstream/`) so the render-docs and Kiwi build steps run
491
+ deterministically on every runner, not just maintainer laptops.
492
+
493
+ ### ⚠️ Migration required
494
+
495
+ - **No persisted-DO-state schema change.** `PERSISTED_STATE_VERSION`
496
+ stays `1`. The ChanServ roster commands, HostServ auto-vhost, and
497
+ the JOIN topic-numeric fix are runtime behaviour, not persisted DO
498
+ fields. **Code rollback across this release is safe.**
499
+ - **No D1 / DynamoDB schema change.** The existing services tables
500
+ (`nickserv_accounts`, `chanserv_channels`, …) carry the new
501
+ behaviour unchanged. The `cert_subjects` column flagged for the
502
+ in-progress services credential consolidation is **not yet
503
+ present** — operators do not need to run any migration command for
504
+ this release.
505
+ - **Behaviour change: late `PASS` no longer emits `462`.** A
506
+ post-registration `PASS <value>` used to reply
507
+ `462 ERR_ALREADYREGISTRED`; it is now silent. Clients that keyed
508
+ off the `462` to detect "you already sent PASS" will no longer see
509
+ it. No client is known to do this (the numeric is informational),
510
+ but scripted regression tests against this behaviour must be
511
+ updated (the in-tree `irc-test-support` scenario was updated).
512
+ - **Behaviour change: late `PASS <nick>:<password>` now logs in.**
513
+ Clients that intentionally sent the colon form post-registration
514
+ expecting it to be ignored will now be logged in. This is the
515
+ intended fix; flag it in operator runbooks if the deployment was
516
+ relying on the silent-reject for some access-control flow.
517
+
518
+ ### Known limitations
519
+
520
+ - **The `AccountStore` / `ServicesStore` credential drift is still
521
+ present.** A nick registered via NickServ `REGISTER` after the
522
+ worker booted still cannot authenticate via SASL PLAIN/EXTERNAL or
523
+ `PASS <nick>:<password>` against the *fresh* password — those paths
524
+ consult the SASL `AccountStore` (`accounts` table), which
525
+ `registerNick` does not write back to. The PASS-login fix above
526
+ makes the at-registration and post-registration paths symmetric,
527
+ but the underlying cross-store drift remains tracked for a
528
+ follow-up release that consolidates the credential into a single
529
+ store. Operators who need a NickServ-registered nick to work with
530
+ SASL/PASS immediately should re-seed via
531
+ `tools/seed-cf-accounts.ts` (D1) / `tools/seed-aws-accounts.ts`
532
+ (DynamoDB) with the same password.
533
+ - Same transport limits as v0.8.0: the **CF TCP path requires
534
+ Cloudflare Spectrum (Enterprise tier)** plus a stateful Container
535
+ origin; the **AWS TCP path** uses NLB + Lambda streaming and is
536
+ subject to Lambda idle-timeout / stream-duration limits. The
537
+ WebSocket path remains the zero-extra-deps default.
538
+ - **mTLS (for SASL `EXTERNAL`) requires a custom domain + uploaded
539
+ CA/trust-store** on both platforms and is not enabled by default.
540
+ - **Client compatibility sweep** (WeeChat / HexChat / IRCCloud /
541
+ TheLounge / matrix-IRC bridge) remains pending.
542
+ - **Coverage hardening is partial.** `irc-core`, `irc-server`, and
543
+ `in-memory-runtime` sit at 100%; `aws-adapter` and `aws-stack`
544
+ clear the 90% gate. The remaining packages (`cf-adapter`,
545
+ `local-cli`, `load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
546
+ `irc-test-support`, `web`) are **above the gate but below 100%** —
547
+ follow-ups drive each to full coverage.
548
+
549
+ ### Security
550
+
551
+ - **NickServ `INFO` email disclosure** (Fixed above): a remote,
552
+ unidentified client could harvest email addresses from every
553
+ registered nick. The disclosure is now gated behind owner-or-oper.
554
+ - **First-joiner op-takeover on empty registered channels** (Fixed
555
+ above): an unidentified client could claim `+o` by being the first
556
+ to join an empty registered channel. The auto-op fallback now
557
+ requires founder/access-list identification.
558
+ - No other auth path changed. The scrypt-hashed credential store,
559
+ server-password gate, and CSWSH defense are unchanged from v0.8.0.
560
+
561
+ ---
562
+
13
563
  ## [0.8.0] - 2026-08-08
14
564
 
15
565
  The main change in this release is **integrated IRC services**: NickServ,