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.
- package/CHANGELOG.md +84 -0
- package/README.md +28 -19
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/registration.test.ts +106 -14
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- 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.
|
|
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
|
|
14
|
-
> server-password enforcement, hostmask cloaking,
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
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
|
-
│
|
|
61
|
-
│
|
|
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
|
-
│
|
|
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
|
-
✅
|
|
320
|
-
|
|
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
|
|
337
|
-
`account-tag`, `echo-message`, `batch`,
|
|
338
|
-
`EXTERNAL` reserved pending mTLS),
|
|
339
|
-
`
|
|
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).
|
|
File without changes
|
|
@@ -22,11 +22,17 @@
|
|
|
22
22
|
* See `wrangler.toml` and `docs/deployment-cf.md`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
##
|
|
112
|
-
##
|
|
113
|
-
##
|
|
114
|
-
## `tag`
|
|
115
|
-
##
|
|
116
|
-
##
|
|
117
|
-
|
|
118
|
-
tag
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
[[
|
|
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"
|
|
@@ -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
|
|
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)
|