serverless-ircd 0.9.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 (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
package/CHANGELOG.md CHANGED
@@ -10,6 +10,311 @@ 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
+
13
318
  ## [0.9.0] - 2026-08-10
14
319
 
15
320
  The main change in this release is **rounding out the integrated IRC
package/README.md CHANGED
@@ -32,20 +32,39 @@ One TypeScript codebase. Two serverless substrates.
32
32
  >
33
33
  > **Web client.** A vendored **Kiwi IRC** SPA is served at
34
34
  > `/webclient/` and a static landing page at `/` directly by the
35
- > Cloudflare Worker, with **Cross-Site WebSocket Hijacking (CSWSH)
36
- > defense** wired into the WS upgrade path (same-origin auto-derive by
37
- > default, optional `WEB_ORIGINS` allowlist for cross-origin
38
- > deploys). The `docs/` wiki is rendered to standalone HTML at
39
- > `/docs/`.
35
+ > Cloudflare Worker (via its `[assets]` binding) **or** by AWS via an
36
+ > **S3 + CloudFront + OAC** `StaticSite` construct (opt-in via CDK
37
+ > context). One `apps/web/dist/` artifact ships on both platforms
38
+ > unchanged. The `docs/` wiki is rendered to standalone HTML at
39
+ > `/docs/`. Both platforms wire **Cross-Site WebSocket Hijacking
40
+ > (CSWSH) defense** into the WS upgrade path: the CF Worker uses
41
+ > same-origin auto-derive by default with an optional `WEB_ORIGINS`
42
+ > allowlist for cross-origin deploys; the AWS `$connect` Lambda uses
43
+ > an explicit `WEB_ORIGINS` allowlist only (the SPA and the API
44
+ > Gateway wss endpoint are on different origins, so auto-derive does
45
+ > not apply). AWS deploys are **stack-output-driven** (a two-phase
46
+ > `scripts/deploy-web-aws.mjs` bakes the real `ConnectUrl` into the
47
+ > SPA config, then `s3 sync` + CloudFront invalidation).
48
+ >
49
+ > **Deploy security.** The AWS CI deploy is **OIDC-only** — the
50
+ > GitHub Actions workflow assumes an IAM role via GitHub OIDC web
51
+ > identity (no long-lived access keys, with an in-workflow assertion
52
+ > that the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any
53
+ > `cdk deploy`). API Gateway full-frame body logging
54
+ > (`DataTraceEnabled`) is **off by default and hard-locked**, so IRC
55
+ > frames (`PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys,
56
+ > `PRIVMSG`/`NOTICE`) are never written to CloudWatch; the only way
57
+ > back on is an explicit two-flag sandbox escape hatch.
40
58
  >
41
59
  > **Integrated IRC services.** NickServ, ChanServ, HostServ,
42
60
  > OperServ, and MemoServ run inside the daemon (no separate services
43
61
  > process, no S2S link) backed by a `ServicesStore` port with
44
62
  > **persistent D1 (Cloudflare) and DynamoDB (AWS) backends**
45
- > (write-behind, surviving redeploys). The SASL `AccountStore` and
46
- > NickServ accounts are **unified** under one scrypt-hashed credential
47
- > store, `PASS <nick>:<password>` identifies at or after registration,
48
- > and `draft/read-marker` / `draft/pre-away` persist through the
63
+ > (write-behind, surviving redeploys). The `ServicesStore` is the
64
+ > **single credential home**: SASL PLAIN, SASL EXTERNAL (CertFP),
65
+ > NickServ `IDENTIFY`, and `PASS <nick>:<password>` all verify through
66
+ > the same scrypt-hashed `verifyNick` / `verifyCertFP` surface, and
67
+ > `draft/read-marker` / `draft/pre-away` persist through the
49
68
  > `ServicesStore`.
50
69
  >
51
70
  > See `CHANGELOG.md` for the per-release manifests.
@@ -161,6 +180,8 @@ ServerlessIRCd/
161
180
  │ ├── ci-hardening/ coverage-gate + mutation-config validators
162
181
  │ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
163
182
  │ └── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
183
+ ├── scripts/
184
+ │ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
164
185
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
165
186
  └── README.md CHANGELOG.md
166
187
  ```
@@ -296,14 +317,15 @@ treats `state.account !== undefined` as already authorised, so a
296
317
  deployment with both `SERVER_PASSWORD` and configured SASL accounts
297
318
  does not need to hand the shared password to identified users. The
298
319
  server-wide gate is a shared deployment secret (never log it); per-user
299
- credentials still go through the hashed SASL account store
300
- (`HashedAccountStore` / D1 / DynamoDB), which is unaffected.
320
+ credentials still go through the unified services credential store
321
+ (`ServicesStore.verifyNick` D1 `nickserv_accounts` / DynamoDB
322
+ `Services`), which is unaffected.
301
323
 
302
324
  **PASS-based account login:** in addition to SASL, a client may identify
303
325
  to its NickServ account by sending `PASS <nick>:<password>` (the same
304
326
  `<nick>:<password>` literal the `SASL_ACCOUNTS` seed tooling uses).
305
- When an `AccountStore` is configured, `NICK alice` + `PASS alice:hunter2`
306
- + `USER …` verifies the credentials against the account store and, on
327
+ When a `ServicesStore` is configured, `NICK alice` + `PASS alice:hunter2`
328
+ + `USER …` verifies the credentials via `services.verifyNick` and, on
307
329
  success, logs the connection in as `alice` — emitting `900 RPL_LOGGEDIN`
308
330
  + `903 RPL_SASLSUCCESS` (the same "account is set" numerics SASL uses)
309
331
  before `001 RPL_WELCOME`, stamping user mode `+r`, and running the same
@@ -442,12 +464,22 @@ report).
442
464
  ## Web client (`apps/web`)
443
465
 
444
466
  A vendored **Kiwi IRC** SPA served at `/webclient/` and a static project
445
- landing page served at `/`, both baked out of `apps/web/dist/` and
446
- served directly by the Cloudflare Worker via its `[assets]` binding.
447
- The browser opens a native `wss://` directly to the Worker's
448
- IRC-over-WebSocket endpoint no proxy, gateway, or transport
449
- adaptation in the request path. See `docs/WebClientGuide.md` for the
450
- end-to-end contributor/operator guide.
467
+ landing page served at `/`, both baked out of `apps/web/dist/`. The same
468
+ artifact is served by **both** platforms:
469
+
470
+ | Platform | HTTP/SPA origin | WebSocket origin | Same-origin? |
471
+ |-------------|----------------------------------------------------|---------------------------------------------------|--------------|
472
+ | Cloudflare | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
473
+ | AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
474
+
475
+ On Cloudflare the Worker's `[assets]` binding serves the SPA and the WS
476
+ upgrade on one origin — the browser opens a native `wss://` directly to
477
+ the Worker, no proxy or gateway. On AWS the SPA is fronted by an
478
+ **S3 + CloudFront + OAC** `StaticSite` construct
479
+ (`apps/aws-stack/src/static-site.ts`, opt-in via CDK context) and a
480
+ two-phase deploy bakes the real `ConnectUrl` into the SPA config (see
481
+ "Deploying on AWS" below). See `docs/WebClientGuide.md` for the CF
482
+ end-to-end guide and `docs/AWS-Deployment.md` §16 for the AWS path.
451
483
 
452
484
  Build the SPA + landing page (needs the submodule + `yarn`, provided by
453
485
  `corepack enable`):
@@ -455,7 +487,7 @@ Build the SPA + landing page (needs the submodule + `yarn`, provided by
455
487
  ```bash
456
488
  git submodule update --init apps/web/upstream # one-time per clone
457
489
  git submodule update --init docs # one-time per clone (docs site)
458
- pnpm --filter web build:staging # or :prod / default
490
+ pnpm --filter web build # or build:prod (CF) / build:prod-aws (AWS)
459
491
  # → apps/web/dist/index.html (landing page, served at /)
460
492
  # → apps/web/dist/webclient/index.html (Kiwi SPA, served at /webclient/)
461
493
  # → apps/web/dist/webclient/static/config.json (baked, env-specific)
@@ -470,8 +502,10 @@ existing `[assets]` binding serves them at `/docs/<slug>.html` (and
470
502
  command if it is missing or empty (mirroring the Kiwi `upstream/` guard).
471
503
  `Home.md` is the docs landing page; `ADR-Index.md` lists every ADR.
472
504
 
473
- Then run everything locally (Worker serves SPA + landing page + WS on
474
- one origin):
505
+ ### Deploying on Cloudflare
506
+
507
+ Run everything locally (Worker serves SPA + landing page + WS on one
508
+ origin):
475
509
 
476
510
  ```bash
477
511
  pnpm build # workspace packages
@@ -482,18 +516,52 @@ pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
482
516
  # /health → plaintext liveness
483
517
  ```
484
518
 
485
- Deploy staging (Worker + assets in one command):
519
+ Deploy the Worker (Worker + assets in one command):
520
+
521
+ ```bash
522
+ pnpm deploy:cf # wrangler deploy
523
+ ```
524
+
525
+ ### Deploying on AWS
526
+
527
+ The web client is **opt-in**: provision the `StaticSite` construct by
528
+ passing `webSite*` CDK context on the stack deploy, and (once the SPA
529
+ ships) set `webOrigins` for the CSWSH defence:
530
+
531
+ ```bash
532
+ pnpm deploy:aws -- \
533
+ -c webSiteCustomDomain=app.example.com \
534
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
535
+ -c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
536
+ -c webOrigins=https://app.example.com
537
+ ```
538
+
539
+ Then bake + ship the SPA (stack-output-driven — reads `ConnectUrl`,
540
+ `WebsiteBucketName`, `WebsiteDistributionId` from the stack outputs,
541
+ bakes the real wss URL into `config.json`, `s3 sync`s the build, and
542
+ invalidates the CloudFront edge cache):
486
543
 
487
544
  ```bash
488
- pnpm deploy:cf:staging # wrangler deploy --env staging
545
+ node scripts/deploy-web-aws.mjs
489
546
  ```
490
547
 
548
+ The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
549
+ See `docs/AWS-Deployment.md` §16 for the full two-phase flow, the
550
+ `server` / `direct_path` config split (irc-framework prepends `wss://`
551
+ itself, so the host is baked scheme-less), and the custom-domain setup.
552
+
491
553
  ### WebSocket Origin policy (CSWSH defense)
492
554
 
493
- WebSocket upgrades do **not** follow the same-origin policy, so the
494
- Worker rejects browser upgrades whose `Origin` is not allowed see
495
- `apps/cf-worker/src/origin-allowlist.ts` and the SPA guide §5. Two
496
- modes, evaluated in order:
555
+ WebSocket upgrades do **not** follow the same-origin policy, so a
556
+ malicious page can open a WebSocket to the IRC server from a victim's
557
+ browser and drive the session with their credentials (Cross-Site
558
+ WebSocket Hijacking, CSWSH). Both adapters enforce an `Origin` policy
559
+ on the upgrade — but the **modes differ**, because the CF Worker serves
560
+ the SPA and the wss endpoint on the same origin while AWS serves them
561
+ on different origins:
562
+
563
+ **Cloudflare** — see `apps/cf-worker/src/origin-allowlist.ts` and the
564
+ SPA guide §5. Two modes, evaluated in order:
497
565
 
498
566
  1. **Explicit allowlist** — set `WEB_ORIGINS` (comma-separated) for
499
567
  cross-origin deploys (SPA on a different domain than the Worker,
@@ -502,11 +570,27 @@ modes, evaluated in order:
502
570
  unset/empty, the Worker compares the browser's `Origin` against the
503
571
  request's own origin. Match → proceed; mismatch → `403 Forbidden`.
504
572
 
505
- `WEB_ORIGINS` is **optional** — same-origin auto-derive needs zero
506
- per-env config and works for `*.workers.dev`, custom domains, and
507
- preview URLs alike. Non-browser clients (curl, WeeChat, the
508
- `tcp-ws-forwarder`, scripted harnesses) never send `Origin` and pass
509
- through unchanged in both modes.
573
+ `WEB_ORIGINS` is **optional** on CF — same-origin auto-derive needs
574
+ zero per-env config and works for `*.workers.dev`, custom domains, and
575
+ preview URLs alike.
576
+
577
+ **AWS** see `packages/aws-adapter/src/origin-allowlist.ts` and
578
+ `docs/AWS-Deployment.md` §8.2. **Explicit allowlist only** (no
579
+ auto-derive): the SPA is on a CloudFront origin and the wss endpoint is
580
+ on an API Gateway origin, so the request's own host is never the SPA's
581
+ origin. The defence is **opt-in** — unset `WEB_ORIGINS` skips the check
582
+ entirely (existing bare-IRC deployments without a web frontend are
583
+ unchanged on upgrade); set it to the SPA's origin(s) once the web client
584
+ ships:
585
+
586
+ ```bash
587
+ pnpm deploy:aws -- -c webOrigins=https://app.example.com
588
+ # or as a stack prop: webOrigins: 'https://app.example.com,https://staging.app.example.com'
589
+ ```
590
+
591
+ Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`, scripted
592
+ harnesses) never send `Origin` and pass through unchanged on both
593
+ platforms.
510
594
 
511
595
  ---
512
596
 
@@ -637,8 +721,9 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
637
721
 
638
722
  - **NickServ** — `REGISTER` / `IDENTIFY` (alias: `ID`) / `DROP` / `INFO`
639
723
  (owner + oper only for the `Email:` line) / `SET ENFORCE` + nick
640
- enforcement on the `NICK` path. `INFO <nick>` with no target resolves
641
- to the caller's own account.
724
+ enforcement on the `NICK` path / `SET PASSWORD` (self-service password
725
+ change; requires the current password re-supplied). `INFO <nick>` with
726
+ no target resolves to the caller's own account.
642
727
  - **ChanServ** — `REGISTER` / `DROP` / `SET` (`FOUNDER` / `MLOCK` /
643
728
  `RESTRICTED` / `KEEPTOPIC`) / `INFO` / `ACCESS` (`SOP` / `AOP` / `HOP`
644
729
  / `VOP`, each `ADD` / `DEL` / `LIST`) / `LEVELS`, plus the prefix /
@@ -658,9 +743,11 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
658
743
  - **MemoServ** — `SEND` / `LIST` / `READ` / `DEL` with queue delivery
659
744
  at identify.
660
745
 
661
- The SASL `AccountStore` and NickServ accounts share one scrypt-hashed
662
- credential store, so a registered nick is also a SASL login and vice
663
- versa. Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
746
+ The `ServicesStore` is the **single credential home**: SASL PLAIN, SASL
747
+ EXTERNAL (CertFP), NickServ `IDENTIFY`, and `PASS <nick>:<password>` all
748
+ verify through the same scrypt-hashed `verifyNick` / `verifyCertFP`
749
+ surface, so a registered nick is also a SASL login and vice versa.
750
+ Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
664
751
  local CLI / tests (all write-behind; registrations survive redeploys).
665
752
  When no store is bound, services commands reply `501` and the rest of
666
753
  the daemon is unaffected. See `docs/Services.md` for the full
@@ -712,9 +799,15 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
712
799
  account store, service shortcut verbs), adapter backends, and an
713
800
  end-to-end registration walkthrough.
714
801
  - `docs/WebClientGuide.md` — end-to-end contributor/operator doc for
715
- the web client: build pipeline, per-env config matrix, CSWSH
716
- rationale and the optional `WEB_ORIGINS` var, local dev, optional
717
- Cloudflare Pages alternative, and troubleshooting.
802
+ the **Cloudflare** web client: build pipeline, per-env config matrix,
803
+ CSWSH rationale and the optional `WEB_ORIGINS` var, local dev,
804
+ optional Cloudflare Pages alternative, and troubleshooting.
805
+ - `docs/AWS-Deployment.md` — end-to-end AWS deploy guide: first-time
806
+ setup, CDK reference, DynamoDB capacity planning, region strategy,
807
+ cost notes, **OIDC-only CI** (§17), **APIGW `DataTraceEnabled` hard-
808
+ lock** (§7.6), and the **S3 + CloudFront + OAC web client** (§16,
809
+ including the two-phase stack-output-driven deploy and the
810
+ `webOrigins` CSWSH knob).
718
811
  - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md`
719
812
  — end-to-end guides for the `:6697` TCP+TLS variants
720
813
  (Spectrum/Container on CF, NLB + Lambda streaming on AWS), including
@@ -21,8 +21,7 @@ The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
21
21
 
22
22
  ```sh
23
23
  pnpm cdk:synth # synthesize the CloudFormation template (cdk.out/)
24
- pnpm deploy:staging # cdk deploy --all
25
- pnpm deploy:prod # cdk deploy --all --require-approval never
24
+ pnpm deploy # cdk deploy --all --require-approval never
26
25
  pnpm test # synth-time assertions (no AWS, no Docker)
27
26
  pnpm typecheck
28
27
  ```
@@ -30,9 +29,8 @@ pnpm typecheck
30
29
  Repo-level shortcuts (from the monorepo root):
31
30
 
32
31
  ```sh
33
- pnpm deploy:aws:staging
34
- pnpm deploy:aws:prod
35
- pnpm smoke:aws:staging # deferred (smoke harness not yet wired)
32
+ pnpm deploy:aws
33
+ pnpm smoke:aws # deferred (smoke harness not yet wired)
36
34
  ```
37
35
 
38
36
  ## Optional localstack validation