serverless-ircd 0.8.0 → 0.9.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
package/README.md CHANGED
@@ -6,48 +6,49 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates.
8
8
 
9
- > **Status:** **v0.8.0 (preview).** The pure protocol core, the
10
- > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
- > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
9
+ > **Status:** **preview.** The pure protocol core, the `IrcRuntime`
10
+ > port + in-memory runtime, a runnable local CLI server, the
11
+ > **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
12
  > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
13
- > deployed to staging. v0.4.0 shipped **dual transport**: both adapters
14
- > speak a real `irc+tls :6697` (TLS-over-TCP) surface for stock IRC
15
- > clients Cloudflare via Spectrum + a Container origin, AWS via a
16
- > Network Load Balancer + Lambda streamingalongside the WebSocket
17
- > path, plus **SASL `EXTERNAL` via mTLS** and a **Cloudflare D1-backed
18
- > SASL account store**. v0.5.0 completed the **protocol surface**: the
19
- > deferred IRC verbs (`KILL`, `REHASH`, `LUSERS` + `STATS`, `TRACE`,
20
- > `WALLOPS`, `SETNAME`) all land, and the S2S (`CONNECT` / `SQUIT` /
21
- > `LINKS`) and obsolete RFC 2812 (`SERVICE` / `SUMMON` / `USERS`) verbs
22
- > are formally dropped. v0.6.0 landed the **IRCv3 extension sweep**
23
- > ten caps (`account-notify`, `msgid`,
24
- > `standard-replies`, `MONITOR`, `labeled-response`, `sts`,
25
- > `draft/typing`, `draft/multiline`, `draft/read-marker`,
26
- > `draft/pre-away`), the ISUPPORT tokens for the new caps, and the
27
- > read-only user mode `S` (TLS connected). **v0.7.0 lands the web
28
- > client**: a vendored **Kiwi IRC** SPA served at `/webclient/`
29
- > and a static landing page at `/` directly by the Cloudflare Worker,
30
- > with **Cross-Site WebSocket Hijacking (CSWSH) defense** wired into
31
- > the WS upgrade path (same-origin auto-derive by default, optional
32
- > `WEB_ORIGINS` allowlist for cross-origin deploys) — the browser
33
- > opens a native `wss://` straight to the existing IRC-over-WebSocket
34
- > endpoint. **v0.8.0 lands integrated IRC services** NickServ
35
- > (`REGISTER` / `IDENTIFY` / `DROP` / `INFO` / `SET ENFORCE` +
36
- > nick enforcement), ChanServ (`REGISTER` / `DROP` / `SET` /
37
- > `ACCESS` / `LEVELS` + the `+r` / `+R` / `+M` channel modes),
38
- > HostServ (`ON` / `OFF` / `REQUEST` + oper `SET` / `APPROVE`),
39
- > OperServ (`AKILL` / `JUPE` / `RAW`), and MemoServ
40
- > (`SEND` / `LIST` / `READ` / `DEL`) — backed by a `ServicesStore`
41
- > port with **persistent D1 (Cloudflare) and DynamoDB (AWS)
42
- > backends** (write-behind, surviving redeploys). The SASL
43
- > `AccountStore` and NickServ accounts are **unified** under one
44
- > scrypt-hashed credential store, `PASS <nick>:<password>` identifies
45
- > at registration, and `draft/read-marker` gains the timestamp-based
46
- > `MARKREAD` verb. Remaining 0.x work: the client compatibility sweep
47
- > (WeeChat / HexChat / IRCCloud / TheLounge), the Playwright
48
- > browser-driven e2e for the SPA, and driving the remaining
49
- > sub-100% packages to full coverage. See `CHANGELOG.md` for the
50
- > per-release manifests.
13
+ > deployed to staging.
14
+ >
15
+ > **Dual transport.** Both adapters speak a real `irc+tls :6697`
16
+ > (TLS-over-TCP) surface for stock IRC clientsCloudflare via
17
+ > Spectrum + a Container origin, AWS via a Network Load Balancer +
18
+ > Lambda streaming alongside the WebSocket path, plus **SASL
19
+ > `EXTERNAL` via mTLS** and a **Cloudflare D1-backed SASL account
20
+ > store**.
21
+ >
22
+ > **Complete protocol surface.** The deferred IRC verbs (`KILL`,
23
+ > `REHASH`, `LUSERS` + `STATS`, `TRACE`, `WALLOPS`, `SETNAME`) are
24
+ > implemented, and the S2S (`CONNECT` / `SQUIT` / `LINKS`) and obsolete
25
+ > RFC 2812 (`SERVICE` / `SUMMON` / `USERS`) verbs are formally dropped.
26
+ >
27
+ > **IRCv3 extensions.** Negotiated caps include `account-notify`,
28
+ > `msgid`, `standard-replies`, `MONITOR`, `labeled-response`, `sts`,
29
+ > `draft/typing`, `draft/multiline`, `draft/read-marker`, and
30
+ > `draft/pre-away`, plus the ISUPPORT tokens for each and the
31
+ > read-only user mode `S` (TLS connected).
32
+ >
33
+ > **Web client.** A vendored **Kiwi IRC** SPA is served at
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/`.
40
+ >
41
+ > **Integrated IRC services.** NickServ, ChanServ, HostServ,
42
+ > OperServ, and MemoServ run inside the daemon (no separate services
43
+ > process, no S2S link) backed by a `ServicesStore` port with
44
+ > **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
49
+ > `ServicesStore`.
50
+ >
51
+ > See `CHANGELOG.md` for the per-release manifests.
51
52
 
52
53
  ---
53
54
 
@@ -299,23 +300,28 @@ credentials still go through the hashed SASL account store
299
300
  (`HashedAccountStore` / D1 / DynamoDB), which is unaffected.
300
301
 
301
302
  **PASS-based account login:** in addition to SASL, a client may identify
302
- to its NickServ account at registration by sending
303
- `PASS <nick>:<password>` (the same `<nick>:<password>` literal the
304
- `SASL_ACCOUNTS` seed tooling uses). When an `AccountStore` is configured,
305
- `NICK alice` + `PASS alice:hunter2` + `USER …` verifies the credentials
306
- against the account store and, on success, logs the connection in as
307
- `alice` — emitting `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS` (the same
308
- "account is set" numerics SASL uses) before `001 RPL_WELCOME`, stamping
309
- user mode `+r`, and running the same read-marker / away / memo replay as
310
- a SASL login. The nick left of the `:` must match the `NICK` sent; a bare
311
- `PASS <value>` with no colon is always treated as a server-password
312
- candidate, never as account credentials. A wrong password or unknown nick
313
- is indistinguishable (no `904`/`464` from the auth path) and the
314
- connection simply proceeds un-identified when no server password is set.
315
- This composes with the gate above by precedence: an already-identified
316
- connection (SASL or PASS-auth) satisfies the gate; a `<nick>:<password>`
317
- that fails verify never matches a bare shared secret, so a server with
318
- both configured still rejects it with `464`.
303
+ to its NickServ account by sending `PASS <nick>:<password>` (the same
304
+ `<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
307
+ success, logs the connection in as `alice` — emitting `900 RPL_LOGGEDIN`
308
+ + `903 RPL_SASLSUCCESS` (the same "account is set" numerics SASL uses)
309
+ before `001 RPL_WELCOME`, stamping user mode `+r`, and running the same
310
+ read-marker / away / memo replay as a SASL login. The login is also
311
+ honoured when `PASS <nick>:<password>` arrives **after** registration
312
+ has completed (e.g. a client that sent `NICK`+`USER` before `PASS`, or
313
+ any `PASS` arriving after `CAP END` resolved): the same verify +
314
+ `applyAccountSuccess` pipeline runs, emitting `900`/`903` and stamping
315
+ `+r` in-band. The nick left of the `:` must match the connection's
316
+ current nick; a bare `PASS <value>` with no colon is always treated as
317
+ a server-password candidate (silent no-op once registration has
318
+ completed), never as account credentials. A wrong password or unknown
319
+ nick is indistinguishable (no `904`/`464` from the auth path) and the
320
+ connection simply proceeds un-identified when no server password is
321
+ set. This composes with the gate above by precedence: an
322
+ already-identified connection (SASL or PASS-auth) satisfies the gate;
323
+ a `<nick>:<password>` that fails verify never matches a bare shared
324
+ secret, so a server with both configured still rejects it with `464`.
319
325
 
320
326
  ### Connecting
321
327
 
@@ -386,7 +392,7 @@ against any line-oriented WebSocket endpoint.
386
392
 
387
393
  ## Load testing (`tools/load-test`)
388
394
 
389
- A synthetic WebSocket IRC client pool (PLAN §7). It opens N
395
+ A synthetic WebSocket IRC client pool. It opens N
390
396
  connections to a `ws://` / `wss://` target, registers each (NICK/USER),
391
397
  joins a channel, and optionally chats. Per-stage **p50 / p95 / p99
392
398
  latency** (connect / register / join / message) and the **drop rate**
@@ -448,12 +454,22 @@ Build the SPA + landing page (needs the submodule + `yarn`, provided by
448
454
 
449
455
  ```bash
450
456
  git submodule update --init apps/web/upstream # one-time per clone
457
+ git submodule update --init docs # one-time per clone (docs site)
451
458
  pnpm --filter web build:staging # or :prod / default
452
459
  # → apps/web/dist/index.html (landing page, served at /)
453
460
  # → apps/web/dist/webclient/index.html (Kiwi SPA, served at /webclient/)
454
461
  # → apps/web/dist/webclient/static/config.json (baked, env-specific)
462
+ # → apps/web/dist/docs/<slug>.html (rendered docs/, served at /docs/)
455
463
  ```
456
464
 
465
+ The build also renders the `docs/` submodule (the Gitea wiki, 23 GFM
466
+ markdown files) to standalone HTML under `apps/web/dist/docs/` so the
467
+ existing `[assets]` binding serves them at `/docs/<slug>.html` (and
468
+ `/docs/` for the index — `Home.md` becomes `index.html`). Requires the
469
+ `docs/` submodule checkout; the build fails loudly with the recovery
470
+ command if it is missing or empty (mirroring the Kiwi `upstream/` guard).
471
+ `Home.md` is the docs landing page; `ADR-Index.md` lists every ADR.
472
+
457
473
  Then run everything locally (Worker serves SPA + landing page + WS on
458
474
  one origin):
459
475
 
@@ -462,6 +478,7 @@ pnpm build # workspace packages
462
478
  pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
463
479
  # / → landing page
464
480
  # /webclient/ → Kiwi SPA (opens ws://localhost:8787/)
481
+ # /docs/ → rendered docs/ (Home → index, per-page HTML)
465
482
  # /health → plaintext liveness
466
483
  ```
467
484
 
@@ -552,92 +569,33 @@ port and an `IdFactory` port are injected so tests are fully deterministic.
552
569
 
553
570
  ## Roadmap
554
571
 
555
- A v1 ships when `irc-core` is at 100% coverage, the parametrized contract
556
- suite passes against every runtime, both adapters are deployed to staging,
557
- and ≥3 reference clients (WeeChat, HexChat, IRCCloud, TheLounge) connect
558
- cleanly. Per-release manifests live in `CHANGELOG.md`.
559
-
560
- - **Foundation** (monorepo, turbo, vitest, biome, CI). ✅
561
- - **Pure protocol engine** — reducers, IRCv3 caps, isupport,
562
- SASL (`PLAIN`), token-bucket flood control, chat history. (most in
563
- **v0.1.0**; SASL/flood/chathistory landed in **v0.2.0**).
564
- - **`IrcRuntime` port** in-memory runtime, `ConnectionActor`,
565
- local CLI, parametrized contract suite. ✅ landed in **v0.1.0**.
566
- - **Cloudflare adapter** (ConnectionDO / ChannelDO / RegistryDO).
567
- landed in **v0.1.0** (staging deploy via CI; prod deploy is manual).
568
- - **AWS adapter** (APIGW WS + Lambda + DynamoDB + CDK).
569
- landed in **v0.2.0** (staging deploy via CI; prod deploy is manual).
570
- - **Observability, security hardening, config, CI gates.**
571
- landed across v0.2.0–v0.4.0 (logger port, server-password, cloaking,
572
- admission limits, coverage + mutation gates, OPER credential auth, the
573
- Cloudflare config loader, and AWS max-clients admission); the
574
- required-`serverName` gate closed in **v0.6.0**.
575
- - **TLS hardening & raw TCP transport** (`irc+tls :6697`).
576
- ✅ landed in **v0.4.0**: generalized `ConnectionActor` transport seam,
577
- Cloudflare Spectrum + Container origin, AWS NLB + Lambda streaming,
578
- mTLS → SASL `EXTERNAL`, transport-parametrized contract suite, ADR-009.
579
- - **Load testing & compatibility sweep.** 🔄 partial as of
580
- **v0.6.0**: the `tools/load-test` 10k-connection harness is in-tree;
581
- the formal 10k-connection report per platform and the client
582
- compatibility matrix (WeeChat / HexChat / IRCCloud / TheLounge) are
583
- still pending.
584
- - **PLAN-FIXES remediation.** ✅ landed across v0.3.0–v0.4.0
585
- (MessageStore wired into all three adapters, AccountStore end-to-end,
586
- the DynamoDB `Accounts` table decided, CF enumeration RPCs, AWS QUIT
587
- fanout, `OPER` / `TAGMSG` / `WHOWAS`, RFC 1459 case-mapping, real
588
- MOTD, config-driven server identity, AWS max-clients admission,
589
- CF D1 SASL store, and a sweep of dead-code / stale-comment cleanup).
590
- - **Runtime & tooling baseline.** ✅ landed in **v0.4.0**
591
- (Node ≥ 24 / pnpm 11).
592
- - **IRCv3 WebSocket extension.** ✅ landed in **v0.4.0**:
593
- `text.ircv3.net` / `binary.ircv3.net` subprotocol negotiation +
594
- per-message framing across the CF Worker, AWS APIGW, local CLI, and
595
- `tcp-ws-forwarder` (510-byte budget, `1009`/`1003` close codes,
596
- legacy fallback).
597
- - **Deferred IRC verbs.** ✅ landed in **v0.5.0**: `KILL`,
598
- `REHASH`, `LUSERS` + `STATS`, `TRACE`, `WALLOPS`, `SETNAME`, plus
599
- the formal drop of the S2S (`CONNECT`/`SQUIT`/`LINKS`) and obsolete
600
- RFC 2812 (`SERVICE`/`SUMMON`/`USERS`) verbs. The protocol surface is
601
- feature-complete for modern clients.
602
- - **IRCv3 extension sweep.** ✅ landed in **v0.6.0**: ten
603
- negotiated caps (`account-notify`, `msgid`, `standard-replies`,
604
- `MONITOR`, `labeled-response`, `sts`, `draft/typing`,
605
- `draft/multiline`, `draft/read-marker`, `draft/pre-away`), the
606
- ISUPPORT tokens for the new caps (`MONITOR`/`MULTILINE`/`TYPING`/
607
- `STATUSMSG`/`EXTBAN`/`ACCOUNTEXTBAN`), the read-only user mode `S`
608
- (TLS connected), and the async push fanout seam (Registry hooks →
609
- `MONITOR` online/offline, `BroadcastWallops`-style caps-gated
610
- delivery).
611
- - **IRC services.** ✅ landed in **v0.8.0**: `ServicesStore` port
612
- + integrated NickServ (`REGISTER`/`IDENTIFY`/`DROP`/`INFO`/`SET
613
- ENFORCE` + nick enforcement), ChanServ (`REGISTER`/`DROP`/`SET`/
614
- `ACCESS`/`LEVELS` + `+r`/`+R`/`+M`), HostServ
615
- (`ON`/`OFF`/`REQUEST` + oper `SET`/`APPROVE`), OperServ
616
- (`AKILL`/`JUPE`/`RAW`), MemoServ (`SEND`/`LIST`/`READ`/`DEL`).
617
- Persistent backends ship alongside — **D1** on Cloudflare,
618
- **DynamoDB** on AWS — both write-behind so registrations survive
619
- redeploys. The SASL `AccountStore` and NickServ accounts are
620
- **unified** under one scrypt-hashed credential store, `PASS
621
- <nick>:<password>` identifies at registration, and `draft/read-marker`
622
- / `draft/pre-away` now persist through the `ServicesStore` (no longer
623
- in-memory-only). See `docs/Services.md`.
624
- - **Web client.** 🔄 partial as of **v0.7.0**: a vendored
625
- Kiwi IRC SPA is served at `/webclient/` and a static landing page at `/`
626
- by the Cloudflare Worker via an `[assets]` binding, with a Zod-
627
- validated per-env config matrix and **CSWSH defense** (same-origin
628
- auto-derive + optional `WEB_ORIGINS` allowlist). The Playwright
629
- headless-browser e2e is the remaining open ticket.
630
- See `docs/WebClientGuide.md` for the contributor/operator guide.
631
- - **Coverage hardening.** 🔄 partial as of **v0.7.0**: `aws-adapter`
632
- and `aws-stack` clear the 90% gate (v0.6.0); the CF packages run
633
- under istanbul (v0.6.0). The remaining packages (`cf-adapter`,
634
- `local-cli`, `load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
635
- `irc-test-support`, `web`) are **above the gate but below 100%** —
636
- follow-ups drive each to 100%.
572
+ A v1 ships when the parametrized contract suite passes against every
573
+ runtime, the client compatibility matrix is signed off, and every
574
+ package clears its coverage gate. Per-release manifests live in
575
+ `CHANGELOG.md`.
576
+
577
+ Active follow-ups:
578
+
579
+ - **Client compatibility sweep** formal sign-off on ≥3 reference
580
+ clients (WeeChat / HexChat / IRCCloud / TheLounge) against both
581
+ deployed adapters, on both the WebSocket and `irc+tls :6697`
582
+ surfaces.
583
+ - **Web client e2e** Playwright headless-browser e2e exercising the
584
+ vendored Kiwi IRC SPA against a deployed stack.
585
+ - **Coverage hardening** `aws-adapter` and `aws-stack` clear the 90%
586
+ gate; the CF packages run under istanbul. The remaining packages
587
+ (`cf-adapter`, `local-cli`, `load-test`, `cf-tcp-container`,
588
+ `tcp-ws-forwarder`, `irc-test-support`, `web`) sit above the gate
589
+ but below 100%; follow-ups drive each to full coverage. `irc-core`,
590
+ `irc-server`, and `in-memory-runtime` are at 100%.
591
+ - **Persistent ChanServ ban list** — ban masks currently live on
592
+ `ChannelState.banMasks` and do not survive an empty-recreate of a
593
+ channel. Extending `ServicesStore` with a persistent ban list is the
594
+ documented next step.
637
595
 
638
596
  ---
639
597
 
640
- ## Protocol scope (v1)
598
+ ## Protocol scope
641
599
 
642
600
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
643
601
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
@@ -654,9 +612,9 @@ disconnect), `REHASH` (config reload via `IrcRuntime.reloadConfig()`),
654
612
  `WALLOPS` (global broadcast to every `+w` user).
655
613
 
656
614
  (Out of scope: the S2S verbs `CONNECT`/`SQUIT`/`LINKS` and the obsolete
657
- RFC 2812 verbs `SERVICE`/`SUMMON`/`USERS` are **formally dropped** as of
658
- v0.5.0 — they return `421 ERR_UNKNOWNCOMMAND`, and their reserved
659
- numerics have been removed. S2S linking is a PLAN non-goal.)
615
+ RFC 2812 verbs `SERVICE`/`SUMMON`/`USERS` are **formally dropped**
616
+ they return `421 ERR_UNKNOWNCOMMAND`, and their reserved numerics have
617
+ been removed. S2S linking is a stated non-goal.)
660
618
 
661
619
  **Channel modes:** `o v b i k l t n m s p`, plus the
662
620
  services-derived modes `r` (registered), `R` (block unidentified
@@ -668,24 +626,45 @@ via `276 RPL_WHOISSECURE`, not settable via `MODE`) and the read-only
668
626
  `r` (registered — set by NickServ `IDENTIFY` / SASL login, surfaced as
669
627
  `+r`, not settable via `MODE`).
670
628
 
671
- **Integrated IRC services** (v0.8.0): the service nicks `NickServ`,
672
- `ChanServ`, `HostServ`, `OperServ`, and `MemoServ` are reserved and
673
- routed to dedicated pure reducers when a `ServicesStore` is bound —
674
- there is **no separate services process and no S2S link**. Reached via
675
- `PRIVMSG <Service> :<subcommand>`:
676
- NickServ (`REGISTER` / `IDENTIFY` / `DROP` / `INFO` / `SET ENFORCE` +
677
- nick enforcement on the `NICK` path), ChanServ (`REGISTER` / `DROP` /
678
- `SET` / `INFO` / `ACCESS` / `LEVELS` + auto-op on `JOIN`), HostServ
679
- (`ON` / `OFF` / `REQUEST` + oper `SET` / `APPROVE` / `LIST`, with
680
- `CHGHOST` fanout), OperServ (`AKILL` / `JUPE` / `UNJUPE` / `RAW`), and
681
- MemoServ (`SEND` / `LIST` / `READ` / `DEL` with queue delivery at
682
- identify). The SASL `AccountStore` and NickServ accounts share one
683
- scrypt-hashed credential store, so a registered nick is also a SASL
684
- login and vice versa. Backends: D1 on Cloudflare, DynamoDB on AWS,
685
- in-memory for the local CLI / tests (all write-behind; registrations
686
- survive redeploys). When no store is bound, services commands reply
687
- `501` and the rest of the daemon is unaffected. See
688
- `docs/Services.md` for the full reference.
629
+ **Integrated IRC services:** the service nicks `NickServ`, `ChanServ`,
630
+ `HostServ`, `OperServ`, and `MemoServ` are reserved and routed to
631
+ dedicated pure reducers when a `ServicesStore` is bound — there is **no
632
+ separate services process and no S2S link**. Each service also accepts a
633
+ **shortcut verb** (`/NICKSERV`, `/NS`, `/CS`, `/HS`, `/MS`, `/OS`, …)
634
+ that the daemon rewrites to the equivalent `PRIVMSG <Service> :<args>`
635
+ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
636
+ <Service> :<subcommand>`:
637
+
638
+ - **NickServ** `REGISTER` / `IDENTIFY` (alias: `ID`) / `DROP` / `INFO`
639
+ (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.
642
+ - **ChanServ** `REGISTER` / `DROP` / `SET` (`FOUNDER` / `MLOCK` /
643
+ `RESTRICTED` / `KEEPTOPIC`) / `INFO` / `ACCESS` (`SOP` / `AOP` / `HOP`
644
+ / `VOP`, each `ADD` / `DEL` / `LIST`) / `LEVELS`, plus the prefix /
645
+ roster mutation verbs `OP`, `DEOP`, `VOICE`, `DEVOICE`, `KICK`, and
646
+ `BAN` / `UNBAN` (each emits the corresponding `:ChanServ MODE` /
647
+ `KICK` broadcast and `ApplyChannelDelta` against the channel
648
+ authority). Auto-op / auto-voice on `JOIN` follows the access list;
649
+ the first joiner of a registered channel no longer gets auto-opped
650
+ merely for being first.
651
+ - **HostServ** — `ON` / `OFF` / `REQUEST` + oper `SET` / `APPROVE` /
652
+ `ACTIVATE` / `REJECT` / `LIST`, with `CHGHOST` fanout. An assigned
653
+ vhost is **auto-applied on identify** (SASL, NickServ `IDENTIFY`, or
654
+ PASS-auth): `state.host` is set, `state.vhostActive` is stamped, and
655
+ any currently-joined `chghost`-capable peers see the broadcast.
656
+ Oper-only subcommands are hidden from non-opers in the help NOTICE.
657
+ - **OperServ** — `AKILL` / `JUPE` / `UNJUPE` / `RAW` (oper-only).
658
+ - **MemoServ** — `SEND` / `LIST` / `READ` / `DEL` with queue delivery
659
+ at identify.
660
+
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
664
+ local CLI / tests (all write-behind; registrations survive redeploys).
665
+ When no store is bound, services commands reply `501` and the rest of
666
+ the daemon is unaffected. See `docs/Services.md` for the full
667
+ reference.
689
668
 
690
669
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
691
670
  `TAGMSG` command), `server-time`, `account-tag`, `account-notify`
@@ -724,42 +703,23 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
724
703
 
725
704
  ## Further reading
726
705
 
727
- - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
728
- the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
729
- gates), v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
730
- ChannelRegistryDO, SASL account persistence), v0.4.0 (dual transport
731
- `irc+tls :6697`, SASL `EXTERNAL`/mTLS, WHOWAS + remaining verbs, D1 account
732
- store, Node 24/pnpm 11), v0.5.0 (deferred IRC verbs `KILL` / `REHASH`
733
- / `LUSERS` + `STATS` / `TRACE` / `WALLOPS` / `SETNAME`, plus the formal
734
- drop of the S2S and obsolete RFC 2812 verbs), v0.6.0 (the IRCv3
735
- extension sweep — `account-notify`, `msgid`, `standard-replies`,
736
- `MONITOR`, `labeled-response`, `sts`, `draft/typing`, `draft/multiline`,
737
- `draft/read-marker`, `draft/pre-away`, plus user mode `S`; the
738
- required-`serverName` gate; `tools/load-test`; aws-adapter / aws-stack
739
- coverage unblocks; CF packages moved to istanbul; deploys now
740
- manual-only), and **v0.7.0** (the web client — vendored Kiwi IRC SPA
741
- at `/webclient/`, static landing page at `/`, Cloudflare Worker
742
- `[assets]` binding, per-env Zod-validated config matrix, and CSWSH
743
- defense via same-origin auto-derive + optional `WEB_ORIGINS`;
744
- `/health` becomes an explicit route; mutation-killing tests for
745
- `irc-core`'s parser / batch / numerics), and **v0.8.0** (integrated
746
- IRC services — NickServ / ChanServ / HostServ / OperServ / MemoServ
747
- backed by a `ServicesStore` port with persistent D1 + DynamoDB
748
- backends; unified SASL + NickServ scrypt-hashed account store;
749
- `PASS <nick>:<password>` registration login; the `draft/read-marker`
750
- `MARKREAD` verb).
706
+ - `CHANGELOG.md` — per-release manifests in Keep a Changelog format.
751
707
  - `docs/Services.md` — operator and contributor reference for the
752
708
  integrated IRC services: architecture (integrated vs. pseudo-client),
753
- the per-service command tables, the `+r`/`+R`/`+M` modes, nick
754
- enforcement, adapter backends, and an end-to-end registration
755
- walkthrough.
756
- - `docs/WebClientGuide.md` end-to-end contributor/operator doc for the
757
- web client: build pipeline, per-env config matrix, CSWSH rationale and
758
- the optional `WEB_ORIGINS` var, local dev, optional Cloudflare Pages
759
- alternative, and troubleshooting.
760
- - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md`
761
- end-to-end guides for the `:6697` TCP+TLS variants (Spectrum/Container on
762
- CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
763
- - `README.md` in each `packages/*` and `apps/*` — per-package notes (e.g.
764
- `packages/aws-adapter/README.md` for the DynamoDB-Local test setup,
765
- `apps/aws-stack/README.md` for CDK commands and localstack validation).
709
+ the per-service command tables (including ChanServ `OP`/`DEOP`/
710
+ `VOICE`/`DEVOICE`/`KICK`/`BAN`/`UNBAN`, HostServ oper approval flow,
711
+ the `+r`/`+R`/`+M` modes, nick enforcement, the unified scrypt
712
+ account store, service shortcut verbs), adapter backends, and an
713
+ end-to-end registration walkthrough.
714
+ - `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.
718
+ - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md`
719
+ — end-to-end guides for the `:6697` TCP+TLS variants
720
+ (Spectrum/Container on CF, NLB + Lambda streaming on AWS), including
721
+ mTLS trust-store setup.
722
+ - `README.md` in each `packages/*` and `apps/*` — per-package notes
723
+ (e.g. `packages/aws-adapter/README.md` for the DynamoDB-Local test
724
+ setup, `apps/aws-stack/README.md` for CDK commands and localstack
725
+ validation).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -95,6 +95,9 @@ interface ConnectionBindings {
95
95
  const DEFAULT_ID_FACTORY = {
96
96
  batchId: () => randomUUID(),
97
97
  nonce: () => randomUUID(),
98
+ // `sessionId` is required for `IdFactory` interface completeness; no
99
+ // current code path invokes it (only batchId/nonce/traceId are called).
100
+ // c8 ignore next 1
98
101
  sessionId: () => randomUUID(),
99
102
  traceId: () => randomUUID(),
100
103
  };
@@ -154,12 +157,21 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
154
157
  const tcpServer = createServer((socket) => {
155
158
  sockets.add(socket);
156
159
 
160
+ // `remoteAddress` is always set on a TCP server's connection socket;
161
+ // the 'unknown' fallback is defensive and unreachable in practice.
162
+ /* c8 ignore start */
157
163
  const ip = socket.remoteAddress ?? 'unknown';
164
+ /* c8 ignore stop */
158
165
  const decision = runtime.admitConnection(ip, undefined);
159
166
  if (!decision.ok) {
167
+ // A freshly-accepted TCP socket is always writable; the else branch
168
+ // (skip the ERROR write) is a defensive guard against an impossible
169
+ // race and cannot be exercised over a real socket.
170
+ /* c8 ignore start */
160
171
  if (socket.writable) {
161
172
  socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
162
173
  }
174
+ /* c8 ignore stop */
163
175
  socket.destroy();
164
176
  sockets.delete(socket);
165
177
  return;
@@ -213,8 +225,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
213
225
  const onListening = (): void => {
214
226
  tcpServer.removeListener('error', onError);
215
227
  const addr = tcpServer.address();
216
- // c8 ignore next 1
228
+ // Inside the 'listening' handler `address()` is always an AddressInfo
229
+ // (TCP server, just bound); the null/string returns only occur for IPC
230
+ // sockets or before listening. The `opts.port` fallback is unreachable.
231
+ /* c8 ignore start */
217
232
  const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
233
+ /* c8 ignore stop */
218
234
  resolve({
219
235
  port,
220
236
  host,
@@ -255,9 +271,13 @@ function attachConnection(
255
271
  ): ConnectionBindings {
256
272
  const id = randomUUID();
257
273
  const state = createConnection({ id, connectedSince: clock.now() });
274
+ // `transport.sourceHost` is always supplied (set to the peer IP) by the
275
+ // only call site above; the else branch is defensive and unreachable.
276
+ /* c8 ignore start */
258
277
  if (transport.sourceHost !== undefined) {
259
278
  state.host = transport.sourceHost;
260
279
  }
280
+ /* c8 ignore stop */
261
281
 
262
282
  const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
263
283
 
@@ -161,6 +161,49 @@ describe('config-loader — container-specific knobs', () => {
161
161
  expect(cfg.server.operCreds).toEqual([{ user: 'admin', password: 'secret' }]);
162
162
  });
163
163
 
164
+ it('exercises the OPER_PASSWORD ?? "" default then rejects partial OPER_USER-only creds', () => {
165
+ // The `?? ''` default for the missing OPER_PASSWORD is exercised, but
166
+ // parseServerConfig then rejects the empty password — partial oper
167
+ // creds are a misconfiguration, not a silently-accepted one.
168
+ expect(() =>
169
+ loadContainerConfig({
170
+ SERVER_NAME: 'irc.test',
171
+ NETWORK_NAME: 'TestNet',
172
+ OPER_USER: 'admin',
173
+ }),
174
+ ).toThrowError(/password/u);
175
+ });
176
+
177
+ it('exercises the OPER_USER ?? "" default then rejects partial OPER_PASSWORD-only creds', () => {
178
+ expect(() =>
179
+ loadContainerConfig({
180
+ SERVER_NAME: 'irc.test',
181
+ NETWORK_NAME: 'TestNet',
182
+ OPER_PASSWORD: 'secret',
183
+ }),
184
+ ).toThrowError(/user/u);
185
+ });
186
+
187
+ it('treats an overflow-numeric CREATED_AT (Infinity) as a verbatim string', () => {
188
+ const cfg = loadContainerConfig({
189
+ SERVER_NAME: 'irc.test',
190
+ NETWORK_NAME: 'TestNet',
191
+ // 400 nines overflows Number.MAX_VALUE → Number() yields Infinity,
192
+ // which is not finite, so parseCreatedAt falls through to the raw string.
193
+ CREATED_AT: '9'.repeat(400),
194
+ });
195
+ expect(cfg.server.createdAt).toBe('9'.repeat(400));
196
+ });
197
+
198
+ it('skips a SASL line whose username is set but password is empty', () => {
199
+ const cfg = loadContainerConfig({
200
+ SERVER_NAME: 'irc.test',
201
+ NETWORK_NAME: 'TestNet',
202
+ SASL_ACCOUNTS: 'alice:\nbob:valid',
203
+ });
204
+ expect(cfg.server.saslAccounts).toEqual([{ username: 'bob', password: 'valid' }]);
205
+ });
206
+
164
207
  it('skips malformed SASL account lines', () => {
165
208
  const cfg = loadContainerConfig({
166
209
  SERVER_NAME: 'irc.test',