serverless-ircd 0.2.0 → 0.3.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/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
package/CHANGELOG.md CHANGED
@@ -10,6 +10,90 @@ For the release process itself — versioning policy, pre-release checklist,
10
10
  cutting a tag, rolling back — see [`docs/release.md`](docs/release.md).
11
11
  Cross-reference `progress.md` / `tickets.md` for per-ticket detail.
12
12
 
13
+ ## [0.3.0] - 2026-07-25
14
+
15
+ ### Added — Operator authentication (`@serverless-ircd/irc-core`)
16
+
17
+ - **`OPER` command reducer**: `OPER <name> <password>` authenticates the
18
+ caller against the deployment's configured operator credentials
19
+ (`OperCred[]`) and, on success, grants the `o` user mode, replying
20
+ `381 RPL_YOUREOPER`. Missing parameters → `461 ERR_NEEDMOREPARAMS`;
21
+ no credentials configured → `491 ERR_NOOPERHOST`; wrong name/password
22
+ → `464 ERR_PASSWDMISMATCH`. Already an operator is an idempotent
23
+ no-op that re-confirms with `381`. `OPER` is the *only* path to oper
24
+ — user `MODE +o` is hard-rejected with `481 ERR_NOPRIVILEGES` — and
25
+ unlocks the `313 RPL_WHOISOPERATOR` branch of WHOIS and the `*`
26
+ oper flag of WHO.
27
+
28
+ ### Added — IRCv3 `TAGMSG` (`@serverless-ircd/irc-core`)
29
+
30
+ - **`TAGMSG` reducer** (message-tags extension): carries tags only, no
31
+ text body. Gated behind the `message-tags` capability (clients that
32
+ did not negotiate it see `421 ERR_UNKNOWNCOMMAND`). Follows NOTICE
33
+ semantics — no numeric error replies on any rejection path — for both
34
+ channel and user targets, and is recorded so `draft/chathistory`
35
+ replay picks up `TAGMSG` lines.
36
+
37
+ ### Added — RFC 1459 case-mapping (`@serverless-ircd/irc-core`)
38
+
39
+ - **`case-fold.ts`**: RFC 1459 case-insensitive comparison for nicks
40
+ and channels, advertised via `005 CASEMAPPING=rfc1459`. Replaces the
41
+ prior ASCII-only comparison across nick collision/registry, kick and
42
+ invite target lookup, and membership matching.
43
+
44
+ ### Added — Registration MOTD (`@serverless-ircd/irc-core`)
45
+
46
+ - The registration welcome now renders the **real configured MOTD**
47
+ (`motd-lines.ts`), replacing the placeholder banner. The `MOTD`
48
+ command and the welcome flow share one rendering path.
49
+
50
+ ### Added — Cloudflare adapter (`@serverless-ircd/cf-adapter`)
51
+
52
+ - **`ChannelRegistryDO`**: a fourth Durable Object holding the channel
53
+ directory (channel name → `ChannelDO` routing), alongside
54
+ `ConnectionDO`, `ChannelDO`, and `RegistryDO`. Wired into the Worker
55
+ exports, bindings, and SQLite migrations.
56
+ - **Cloudflare config loader**: reduces Workers `vars` to the shared
57
+ `ServerConfigSchema`, matching the AWS (`loadServerConfigFromLambdaEnv`)
58
+ and local-CLI loaders.
59
+ - `CfRuntime` completed — the previously stubbed `IrcRuntime` methods
60
+ (`getConnection`, `getChannelConnections`, `listChannels`, and
61
+ cross-connection `disconnect`) are now implemented.
62
+
63
+ ### Added — SASL account persistence (`@serverless-ircd/aws-adapter`)
64
+
65
+ - SASL `PLAIN` accounts are now persisted in the DynamoDB `Accounts`
66
+ table with **scrypt** password hashes (never plaintext). The Lambda
67
+ cold-start path scans the table once and loads credentials into the
68
+ synchronous `AccountStore` port.
69
+ - **`tools/seed-aws-accounts.ts`**: provisioning script that scrypt-hashes
70
+ `username:password` pairs (from CLI args or a file) and writes them
71
+ to the `Accounts` table. Re-running with the same username overwrites
72
+ the prior row.
73
+
74
+ ### Changed
75
+
76
+ - `@serverless-ircd/in-memory-runtime`: `admitConnection` now returns a
77
+ stable empty `recordId` when admission is disabled, dropping the
78
+ per-call id minting (the id was never consulted).
79
+ - Deploy scripts: root `pnpm deploy:aws:staging` / `deploy:aws:prod`
80
+ now run `pnpm build` before `cdk deploy`, so the esbuild-bundled
81
+ Lambda picks up fresh workspace `dist/` — matching what CI already
82
+ did and fixing local deploys shipping stale code.
83
+
84
+ ### Known limitations
85
+
86
+ - SASL `EXTERNAL` (mTLS) and production TLS transport remain pending
87
+ (0.x work).
88
+ - Deployed stacks remain WebSocket-only; stock TCP IRC clients still
89
+ reach them through the local `tcp-ws-forwarder` bridge.
90
+
91
+ ### Security
92
+
93
+ - None. No vulnerabilities have been reported or fixed in this release.
94
+
95
+ ---
96
+
13
97
  ## [0.2.0] - 2026-07-23
14
98
 
15
99
  ### Added — Authentication & flood control (`@serverless-ircd/irc-core`)
package/README.md CHANGED
@@ -6,16 +6,20 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates. No S2S linking in v1.
8
8
 
9
- > **Status:** **v0.2.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.3.0 (preview).** The pure protocol core, the
10
10
  > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
11
  > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
12
  > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
13
- > deployed to staging. SASL (`PLAIN`), token-bucket flood control,
14
- > server-password enforcement, hostmask cloaking, connection admission,
15
- > IRCv3 chat history, and a CI coverage + mutation-testing gate landed
16
- > in v0.2.0. Remaining 0.x work: oper credentials /
17
- > config loader, SASL `EXTERNAL` (mTLS), and production TLS transport.
18
- > See `CHANGELOG.md` for the per-release manifests.
13
+ > deployed to staging. v0.2.0 delivered SASL (`PLAIN`), token-bucket
14
+ > flood control, server-password enforcement, hostmask cloaking,
15
+ > connection admission, and IRCv3 chat history; v0.3.0 adds **OPER**
16
+ > credential auth, the IRCv3 **TAGMSG** command, **RFC 1459
17
+ > case-mapping**, a real **MOTD** in the registration welcome, a
18
+ > **ChannelRegistryDO** (4th Cloudflare Durable Object) plus a
19
+ > Cloudflare config loader, and **SASL account persistence** in
20
+ > DynamoDB (scrypt hashes) with a `seed-aws-accounts` provisioning
21
+ > tool. Remaining 0.x work: SASL `EXTERNAL` (mTLS) and production TLS
22
+ > transport. See `CHANGELOG.md` for the per-release manifests.
19
23
 
20
24
  ---
21
25
 
@@ -57,8 +61,9 @@ Hexagonal / ports-and-adapters. The core reasons about IRC; adapters move bytes.
57
61
  │ packages/cf-adapter │ │ packages/aws-adapter │
58
62
  │ ConnectionDO │ │ $connect/$disconnect/ │
59
63
  │ ChannelDO │ │ $default Lambda handlers │
60
- RegistryDO │ │ DynamoDB tables │
61
- CfRuntime │ │ AwsRuntime │
64
+ ChannelRegistryDO │ │ DynamoDB tables │
65
+ RegistryDO │ │ AwsRuntime │
66
+ │ CfRuntime │ │ │
62
67
  └────────────────────────────┘ └──────────────────────────────┘
63
68
  ```
64
69
 
@@ -110,7 +115,8 @@ ServerlessIRCd/
110
115
  │ └── local-cli/ runnable WS + TCP server using in-memory-runtime
111
116
  ├── tools/
112
117
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
113
- └── ci-hardening/ coverage-gate + mutation-config validators
118
+ ├── ci-hardening/ coverage-gate + mutation-config validators
119
+ │ └── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
114
120
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
115
121
  └── README.md CHANGELOG.md
116
122
  ```
@@ -316,9 +322,9 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
316
322
  - **Phase 4** — AWS adapter (APIGW WS + Lambda + DynamoDB + CDK).
317
323
  ✅ landed in **v0.2.0** (staging auto-deploy via CI; prod deploy is manual).
318
324
  - **Phase 5** — Observability, security hardening, config, CI gates.
319
- mostly landed (logger port, server-password, cloaking, admission
320
- limits, coverage + mutation gates). Remaining: oper credentials / config
321
- loader.
325
+ ✅ landed (logger port, server-password, cloaking, admission limits,
326
+ coverage + mutation gates, OPER credential auth, and the Cloudflare
327
+ config loader all shipped across v0.2.0–v0.3.0).
322
328
  - **Phase 6** — Load testing, client compatibility sweep, ADRs.
323
329
 
324
330
  ---
@@ -328,15 +334,17 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
328
334
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
329
335
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
330
336
  channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`, `MOTD`,
331
- `AWAY`.
337
+ `AWAY`, `OPER` (credential auth → `o` user mode).
332
338
 
333
339
  **Channel modes:** `o v b i k l t n m s p`.
334
340
  **User modes:** `i`, `o` (local only), `w`, `s`.
335
341
 
336
- **IRCv3 extensions (negotiated via `CAP`):** `message-tags`, `server-time`,
337
- `account-tag`, `echo-message`, `batch`, `sasl` (`PLAIN` implemented,
338
- `EXTERNAL` reserved pending mTLS), `multi-prefix`, `away-notify`, `chghost`,
339
- `invite-notify`, `extended-join`, `draft/chathistory`, `safelist`.
342
+ **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
343
+ `TAGMSG` command), `server-time`, `account-tag`, `echo-message`, `batch`,
344
+ `sasl` (`PLAIN` implemented, `EXTERNAL` reserved pending mTLS),
345
+ `multi-prefix`, `away-notify`, `chghost`, `invite-notify`, `extended-join`,
346
+ `draft/chathistory`, `safelist`. Case-insensitive nick/channel comparison
347
+ uses **RFC 1459** case-mapping (advertised via `005 CASEMAPPING=rfc1459`).
340
348
 
341
349
  **Transport:** deployed stacks (Cloudflare Workers, AWS API Gateway) speak
342
350
  WebSocket text frames only (one IRC message per frame, with tolerance for
@@ -351,7 +359,8 @@ WebSocket endpoint through the local
351
359
 
352
360
  - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
353
361
  the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
354
- gates).
362
+ gates) and v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
363
+ ChannelRegistryDO, SASL account persistence).
355
364
  - `README.md` in each `packages/*` and `apps/*` — per-package notes (e.g.
356
365
  `packages/aws-adapter/README.md` for the DynamoDB-Local test setup,
357
366
  `apps/aws-stack/README.md` for CDK commands and localstack validation).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
File without changes
@@ -22,11 +22,17 @@
22
22
  * See `wrangler.toml` and `docs/deployment-cf.md`.
23
23
  */
24
24
 
25
- import { ChannelDO, ConnectionDO, type Env, RegistryDO } from '@serverless-ircd/cf-adapter';
25
+ import {
26
+ ChannelDO,
27
+ ChannelRegistryDO,
28
+ ConnectionDO,
29
+ type Env,
30
+ RegistryDO,
31
+ } from '@serverless-ircd/cf-adapter';
26
32
 
27
33
  // Re-exported for wrangler DO binding resolution. The class names below
28
34
  // MUST match `class_name` in `wrangler.toml`.
29
- export { ChannelDO, ConnectionDO, RegistryDO };
35
+ export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO };
30
36
  export type { Env };
31
37
 
32
38
  /**
@@ -26,6 +26,7 @@ declare global {
26
26
  CONNECTION_DO: DurableObjectNamespace;
27
27
  REGISTRY_DO: DurableObjectNamespace;
28
28
  CHANNEL_DO: DurableObjectNamespace;
29
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
29
30
  SERVER_NAME: string;
30
31
  NETWORK_NAME: string;
31
32
  MOTD_LINES: string;
@@ -28,6 +28,10 @@ class_name = "RegistryDO"
28
28
  name = "CHANNEL_DO"
29
29
  class_name = "ChannelDO"
30
30
 
31
+ [[durable_objects.bindings]]
32
+ name = "CHANNEL_REGISTRY_DO"
33
+ class_name = "ChannelRegistryDO"
34
+
31
35
  [[migrations]]
32
36
  tag = "v1"
33
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
37
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
@@ -91,6 +91,10 @@ class_name = "RegistryDO"
91
91
  name = "CHANNEL_DO"
92
92
  class_name = "ChannelDO"
93
93
 
94
+ [[durable_objects.bindings]]
95
+ name = "CHANNEL_REGISTRY_DO"
96
+ class_name = "ChannelRegistryDO"
97
+
94
98
  # Staging environment bindings (wrangler requires per-env blocks).
95
99
  [[env.staging.durable_objects.bindings]]
96
100
  name = "CONNECTION_DO"
@@ -104,20 +108,40 @@ class_name = "RegistryDO"
104
108
  name = "CHANNEL_DO"
105
109
  class_name = "ChannelDO"
106
110
 
107
- ## ---------------------------------------------------------------------------
108
- ## Durable Object migrations.
109
- ## ---------------------------------------------------------------------------
110
- ## `tag` is the user-defined migration id; wrangler refuses to deploy if
111
- ## the on-disk migrations don't match the live Workers state. `new_classes`
112
- ## declares the three DO classes on first deploy; subsequent schema
113
- ## changes (renames, deletions, splits) extend this list with their own
114
- ## `tag` so the runtime can migrate persisted state.
115
- ##
116
- ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
117
- [[migrations]]
118
- tag = "v1"
119
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
120
-
121
- [[env.staging.migrations]]
122
- tag = "v1"
123
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
111
+ [[env.staging.durable_objects.bindings]]
112
+ name = "CHANNEL_REGISTRY_DO"
113
+ class_name = "ChannelRegistryDO"
114
+
115
+ # ## ---------------------------------------------------------------------------
116
+ # ## Durable Object migrations.
117
+ # ## ---------------------------------------------------------------------------
118
+ # ## `tag` is the user-defined migration id; wrangler refuses to deploy if
119
+ # ## the on-disk migrations don't match the live Workers state. `new_classes`
120
+ # ## declares the three DO classes on first deploy; subsequent schema
121
+ # ## changes (renames, deletions, splits) extend this list with their own
122
+ # ## `tag` so the runtime can migrate persisted state.
123
+ # ##
124
+ # ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
125
+ # [[migrations]]
126
+ # tag = "v1"
127
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
128
+
129
+ # [[env.staging.migrations]]
130
+ # tag = "v1"
131
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
132
+
133
+ [exports.ConnectionDO]
134
+ type = "durable-object"
135
+ storage = "sqlite"
136
+
137
+ [exports.RegistryDO]
138
+ type = "durable-object"
139
+ storage = "sqlite"
140
+
141
+ [exports.ChannelDO]
142
+ type = "durable-object"
143
+ storage = "sqlite"
144
+
145
+ [exports.ChannelRegistryDO]
146
+ type = "durable-object"
147
+ storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -32,6 +32,7 @@ export interface CliConfigInput {
32
32
  channelLen?: number;
33
33
  topicLen?: number;
34
34
  quitMessage?: string;
35
+ saslAccounts?: Array<{ username: string; password: string }>;
35
36
  }
36
37
 
37
38
  /**
@@ -14,7 +14,7 @@
14
14
  * Both listeners share one runtime, so a TCP client and a WS client can
15
15
  * see each other (cross-transport channel broadcast). Pass `--no-tcp` to
16
16
  * run WebSocket-only; the WebSocket port / hostname / MOTD are tunable via
17
- * flags (a fuller config-loader lands in a later ticket).
17
+ * flags.
18
18
  *
19
19
  * Usage:
20
20
  * pnpm --filter local-cli start -- --port 6667 --tcp-port 6668 --host 127.0.0.1
@@ -28,10 +28,12 @@ import { randomUUID } from 'node:crypto';
28
28
  import { type Server, type Socket, createServer } from 'node:net';
29
29
  import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
30
30
  import {
31
+ type AccountStore,
31
32
  type AdmissionConfig,
32
33
  type ChanName,
33
34
  type ConnectionState,
34
35
  ConsoleLogger,
36
+ InMemoryAccountStore,
35
37
  InMemoryMessageStore,
36
38
  LogLevel,
37
39
  type Logger,
@@ -57,6 +59,7 @@ type ResolvedServerConfig = Omit<
57
59
  | 'maxConnectionsPerUser'
58
60
  | 'perIpConnectionRate'
59
61
  | 'historyMaxPerChannel'
62
+ | 'saslAccounts'
60
63
  > &
61
64
  Pick<
62
65
  LocalServerConfig,
@@ -65,6 +68,7 @@ type ResolvedServerConfig = Omit<
65
68
  | 'maxConnectionsPerIp'
66
69
  | 'maxConnectionsPerUser'
67
70
  | 'perIpConnectionRate'
71
+ | 'saslAccounts'
68
72
  >;
69
73
 
70
74
  /** Default per-IP / per-user caps used when callers don't override. */
@@ -121,6 +125,13 @@ export interface LocalServerConfig {
121
125
  * CLI — the store is bound unconditionally).
122
126
  */
123
127
  historyMaxPerChannel?: number;
128
+ /**
129
+ * SASL PLAIN account credentials. When non-empty the server seeds an
130
+ * in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
131
+ * listed accounts. When omitted/empty, SASL account verification is
132
+ * disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
133
+ */
134
+ saslAccounts?: Array<{ username: string; password: string }>;
124
135
  }
125
136
 
126
137
  export interface StartServerOptions extends LocalServerConfig {
@@ -178,6 +189,7 @@ const DEFAULT_CONFIG: Omit<
178
189
  | 'maxConnectionsPerUser'
179
190
  | 'perIpConnectionRate'
180
191
  | 'historyMaxPerChannel'
192
+ | 'saslAccounts'
181
193
  > = {
182
194
  serverName: 'irc.example.com',
183
195
  networkName: 'ExampleNet',
@@ -242,6 +254,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
242
254
  ...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
243
255
  ...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
244
256
  ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
257
+ ...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
245
258
  });
246
259
  } catch (err) {
247
260
  return Promise.reject(err);
@@ -268,6 +281,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
268
281
  ...(opts.perIpConnectionRate !== undefined
269
282
  ? { perIpConnectionRate: opts.perIpConnectionRate }
270
283
  : {}),
284
+ ...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
285
+ ? { saslAccounts: opts.saslAccounts }
286
+ : {}),
271
287
  };
272
288
  const hostname = opts.hostname ?? '127.0.0.1';
273
289
 
@@ -295,6 +311,14 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
295
311
  // is replayable when bob queries CHATHISTORY). The cap is config-driven.
296
312
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
297
313
 
314
+ // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
315
+ // end-to-end. `undefined` (no accounts configured) leaves the actor's
316
+ // `ctx.accounts` unset, preserving the no-store behaviour.
317
+ const accounts =
318
+ cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
319
+ ? new InMemoryAccountStore(cfg.saslAccounts)
320
+ : undefined;
321
+
298
322
  const wss = new WebSocketServer({ port: opts.port, host: hostname });
299
323
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
300
324
 
@@ -312,7 +336,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
312
336
  const admissionRecordId = decision.recordId;
313
337
  runtime.commitAdmission(ip, undefined, admissionRecordId);
314
338
 
315
- const { state, actor } = attachConnection(runtime, cfg, messages, {
339
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
316
340
  sendText: (text) => {
317
341
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
318
342
  },
@@ -389,7 +413,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
389
413
  const admissionRecordId = decision.recordId;
390
414
  runtime.commitAdmission(ip, undefined, admissionRecordId);
391
415
 
392
- const { state, actor } = attachConnection(runtime, cfg, messages, {
416
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
393
417
  sendText: (text) => {
394
418
  if (socket.writable) socket.write(text);
395
419
  },
@@ -481,6 +505,7 @@ function attachConnection(
481
505
  runtime: InMemoryRuntime,
482
506
  cfg: ResolvedServerConfig,
483
507
  messages: MessageStore,
508
+ accounts: AccountStore | undefined,
484
509
  transport: {
485
510
  sendText: (text: string) => void;
486
511
  closeTransport: () => void;
@@ -523,6 +548,7 @@ function attachConnection(
523
548
  ids: DEFAULT_ID_FACTORY,
524
549
  motd: { lines: () => cfg.motdLines },
525
550
  messages,
551
+ ...(accounts !== undefined ? { accounts } : {}),
526
552
  logger,
527
553
  });
528
554
 
@@ -418,3 +418,55 @@ describe('local-cli chathistory end-to-end', () => {
418
418
  await srv.close();
419
419
  });
420
420
  });
421
+
422
+ describe('local-cli SASL PLAIN end-to-end', () => {
423
+ /** base64(`\0authcid\0password`) — the PLAIN payload format. */
424
+ function plainB64(authcid: string, password: string): string {
425
+ return Buffer.from(`\0${authcid}\0${password}`, 'utf8').toString('base64');
426
+ }
427
+
428
+ it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
429
+ const srv = await startLocalServer({
430
+ port: 0,
431
+ hostname: '127.0.0.1',
432
+ saslAccounts: [{ username: 'sasl-alice', password: 's3cret' }],
433
+ });
434
+
435
+ const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
436
+ await client.opened();
437
+ client.send('CAP REQ :sasl\r\n');
438
+ await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
439
+ client.send('AUTHENTICATE PLAIN\r\n');
440
+ await client.waitFor((l) => l === 'AUTHENTICATE +');
441
+ client.send(`AUTHENTICATE ${plainB64('sasl-alice', 's3cret')}\r\n`);
442
+ await client.waitFor((l) => l.includes(' 900 '));
443
+ const success = await client.waitFor((l) => l.includes(' 903 '));
444
+ expect(success).toContain(' 903 ');
445
+ client.send('NICK sasl-alice\r\n');
446
+ client.send('USER sasl-alice 0 * :Sasl Alice\r\n');
447
+ client.send('CAP END\r\n');
448
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
449
+ await client.close();
450
+ await srv.close();
451
+ });
452
+
453
+ it('rejects SASL PLAIN with 904 for invalid creds', async () => {
454
+ const srv = await startLocalServer({
455
+ port: 0,
456
+ hostname: '127.0.0.1',
457
+ saslAccounts: [{ username: 'sasl-bob', password: 'right' }],
458
+ });
459
+
460
+ const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
461
+ await client.opened();
462
+ client.send('CAP REQ :sasl\r\n');
463
+ await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
464
+ client.send('AUTHENTICATE PLAIN\r\n');
465
+ await client.waitFor((l) => l === 'AUTHENTICATE +');
466
+ client.send(`AUTHENTICATE ${plainB64('sasl-bob', 'wrong')}\r\n`);
467
+ const fail = await client.waitFor((l) => l.includes(' 904 '));
468
+ expect(fail).toContain(' 904 ');
469
+ await client.close();
470
+ await srv.close();
471
+ });
472
+ });
@@ -0,0 +1,74 @@
1
+ # ADR-001: Pure reducers and the effect system
2
+
3
+ **Date:** 2025-01-15 (Phase 1)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ Every IRC daemon must decide where side effects happen. A traditional ircd
11
+ threads mutable state and IO through command handlers — the handler reads a
12
+ channel roster, sends messages, mutates a user record, and returns. That makes
13
+ handlers hard to unit-test (they require a live socket, a real timer, a
14
+ populated data store) and hard to reason about (the handler's observable
15
+ behaviour is inseparable from its IO).
16
+
17
+ ServerlessIRCd targets two cloud adapters (Cloudflare Workers, AWS Lambda)
18
+ that share one protocol core. If the core performed IO directly, each adapter
19
+ would need its own mock surface for every core test — or worse, the core
20
+ would grow adapter-specific code paths.
21
+
22
+ ## Decision
23
+
24
+ Every command handler is a **pure function** with the signature:
25
+
26
+ ```ts
27
+ type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => { state: S; effects: Effect[] };
28
+ ```
29
+
30
+ - **No IO in the reducer.** No `await`, no `fetch`, no `setTimeout`, no
31
+ `Date.now()`, no `Math.random()`. The reducer reads `ctx.clock.now()` and
32
+ `ctx.ids.nonce()` instead (see ADR-007).
33
+ - **Side effects are values.** The reducer returns an `Effect[]` — a
34
+ discriminated union (`Send`, `Broadcast`, `Disconnect`, `ReserveNick`,
35
+ `ChangeNick`, `ReleaseNick`, `ApplyChannelDelta`, `SendToNick`, …). Each
36
+ effect tag maps one-to-one to an `IrcRuntime` method.
37
+ - **A single `dispatch(effects, runtime)` function** in `irc-server`
38
+ interprets the effects against the bound runtime. Only `dispatch` awaits.
39
+ - **Reducer purity is enforced by convention and testability, not by a
40
+ sandbox.** The type system carries the contract; the 100% coverage gate on
41
+ `irc-core` verifies it empirically.
42
+
43
+ ## Consequences
44
+
45
+ **Positive:**
46
+ - Every reducer is a trivial unit test: arrange state, apply message, assert
47
+ `state` and `effects`. No mocks, no fakes beyond an injected `Clock` and
48
+ `IdFactory`.
49
+ - The same reducer code runs unchanged on in-memory, CF, and AWS runtimes.
50
+ Adapter work is "wire the runtime", not "rewrite the handler".
51
+ - Reducer behaviour is deterministic and reproducible — critical for the
52
+ parametrized contract suite (TICKET-032) that runs identical scenarios
53
+ across all three runtimes.
54
+ - Mutation testing (Stryker, TICKET-048) is meaningful because reducers have
55
+ no hidden IO to mask a mutation's effect.
56
+
57
+ **Negative:**
58
+ - The `Effect` union grows with every new side-effect type. Adding a novel
59
+ effect (e.g. `QueryHistory` for chathistory) requires touching `effects.ts`,
60
+ `dispatch.ts`, and every runtime implementation. This is by design (the
61
+ type system makes missing-runtime-support a compile error) but adds
62
+ ceremony.
63
+ - Reducers cannot directly observe the result of a side effect they emit
64
+ (e.g. "did the broadcast actually reach anyone?"). Cross-effect
65
+ coordination is deferred to the actor layer, which can pre-fetch runtime
66
+ state before invoking the reducer.
67
+
68
+ ## References
69
+
70
+ - PLAN §2.1 — "The key idea: pure reducers + location-of-authority"
71
+ - PLAN §2.2 — `IrcRuntime` port
72
+ - `packages/irc-core/src/effects.ts` — `Effect` union
73
+ - `packages/irc-core/src/types.ts` — `Reducer<S>` signature
74
+ - `packages/irc-server/src/dispatch.ts` — `dispatch(effects, runtime)`
@@ -0,0 +1,82 @@
1
+ # ADR-002: Location-of-authority state split
2
+
3
+ **Date:** 2025-01-15 (Phase 1)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ An IRC server manages several kinds of state: connection records (nick, caps,
11
+ away status), channel rosters (who is in `#foo`, with what prefix), channel
12
+ metadata (topic, modes, ban lists), and the nick registry (nick → connection
13
+ map). In a single-process ircd all of this lives in one address space, so
14
+ read-modify-write races are handled with locks or a single event loop.
15
+
16
+ ServerlessIRCd runs on stateless-per-request compute (Lambda) or
17
+ per-entity-isolated compute (Durable Objects). There is no global lock. If
18
+ two connections race to JOIN the same channel, or two clients race to claim
19
+ the same nick, the outcome must still be correct. Traditional locking is
20
+ impossible across instances.
21
+
22
+ ## Decision
23
+
24
+ Each piece of state has **exactly one authority** — the single entity that
25
+ owns the canonical copy and serializes all mutations to it. Reducers are
26
+ routed to the authority that owns the state they mutate:
27
+
28
+ | Command family | Authority | Primary state |
29
+ |---|---|---|
30
+ | Registration (NICK/USER/PASS) | Connection entity | `ConnectionState` |
31
+ | PING/PONG, QUIT, AWAY, user MODE | Connection entity | `ConnectionState` |
32
+ | JOIN/PART/channel MODE/TOPIC/KICK/INVITE | Channel entity | `ChannelState` + roster |
33
+ | PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) |
34
+ | PRIVMSG/NOTICE to user | Sender Connection | Nick → Connection route |
35
+ | Nick reservation | Registry entity | Nick → Connection map |
36
+
37
+ Cross-authority coordination is done via **effects**, not direct mutation.
38
+ When a connection accepts a JOIN, the connection-authority reducer emits an
39
+ `ApplyChannelDelta` effect that the channel authority consumes. The channel
40
+ authority is the sole writer of its roster; the connection never touches it
41
+ directly.
42
+
43
+ Platform mapping:
44
+ - **Cloudflare:** each authority is a Durable Object (`ConnectionDO`,
45
+ `ChannelDO`, `RegistryDO`). DO single-threaded serial execution gives the
46
+ uniqueness invariant for free — two concurrent requests to the same DO are
47
+ queued, not racing.
48
+ - **AWS:** each authority is a DynamoDB table with conditional writes
49
+ (`ConditionExpression`) or `TransactWriteItems` for multi-row atomicity
50
+ (e.g. JOIN spans `Connections` and `ChannelMembers`).
51
+
52
+ ## Consequences
53
+
54
+ **Positive:**
55
+ - Races are structurally impossible, not defended against. Two clients
56
+ claiming the same nick both route to the same `RegistryDO` shard (or the
57
+ same `Nicks` row with a conditional PutItem) — exactly one wins.
58
+ - The reducer signature stays uniform (`Reducer<S>`) regardless of which
59
+ authority it runs in. The actor layer routes; the reducer does not know or
60
+ care whether it is running in a DO or a Lambda.
61
+ - Each authority can be tested in isolation. The channel reducer tests
62
+ arrange a `ChannelState` and assert effects; they never need a live
63
+ connection registry.
64
+
65
+ **Negative:**
66
+ - Cross-authority coordination requires a round-trip (DO `stub()` call on CF;
67
+ extra DynamoDB write on AWS). A JOIN that updates both the connection's
68
+ joined-list and the channel roster is two writes, not one.
69
+ - The actor layer must pre-fetch authoritative state before invoking a
70
+ reducer (e.g. WHOIS needs the target connection's state from a different
71
+ authority). This adds a `prefetchChannelState` step in the actor pipeline.
72
+ - Listing all channels (LIST) or resolving every member of a channel (WHO)
73
+ requires aggregating across authorities — there is no global view. The CF
74
+ adapter needs a channel registry (TICKET-073); the AWS adapter does a
75
+ DynamoDB scan.
76
+
77
+ ## References
78
+
79
+ - PLAN §2.1 — location-of-authority table
80
+ - PLAN §4 — state model & authoritative ownership
81
+ - ADR-001 — pure reducers (how effects flow between authorities)
82
+ - `packages/irc-server/src/actor.ts` — `route()` switch (authority routing)