serverless-ircd 0.7.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,198 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import { buildDocs, renderMarkdown, rewriteDocLinks } from '../src/render-docs';
15
+
16
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const docsSrc = path.resolve(pkgRoot, '..', '..', 'docs');
18
+
19
+ // ============================================================================
20
+ // renderMarkdown — GFM rendering of markdown source
21
+ // ============================================================================
22
+ describe('renderMarkdown', () => {
23
+ it('renders an ATX H1 heading as <h1>', () => {
24
+ const html = renderMarkdown('# Title');
25
+ expect(html).toMatch(/<h1[^>]*>\s*Title\s*<\/h1>/);
26
+ });
27
+
28
+ it('renders a fenced code block as <pre><code>', () => {
29
+ const md = ['```ts', 'const x = 1;', '```'].join('\n');
30
+ const html = renderMarkdown(md);
31
+ expect(html).toMatch(/<pre[^>]*><code[^>]*>/);
32
+ });
33
+
34
+ it('renders GFM tables', () => {
35
+ const md = ['| a | b |', '| --- | --- |', '| 1 | 2 |'].join('\n');
36
+ const html = renderMarkdown(md);
37
+ expect(html).toContain('<table>');
38
+ expect(html).toContain('<th');
39
+ });
40
+
41
+ it('renders inline code', () => {
42
+ const html = renderMarkdown('Use `npm` to install.');
43
+ expect(html).toContain('<code>npm</code>');
44
+ });
45
+ });
46
+
47
+ // ============================================================================
48
+ // rewriteDocLinks — intra-docs link rewriting
49
+ // ============================================================================
50
+ describe('rewriteDocLinks', () => {
51
+ const known = new Set([
52
+ 'Services',
53
+ 'Home',
54
+ 'ADR-Index',
55
+ 'ADR-001-pure-reducers-and-effect-system',
56
+ ]);
57
+
58
+ it('rewrites a relative OtherDoc.md href to OtherDoc.html', () => {
59
+ const input = '<a href="./Services.md">Services</a>';
60
+ const out = rewriteDocLinks(input, known);
61
+ expect(out).toContain('href="Services.html"');
62
+ expect(out).not.toContain('Services.md');
63
+ });
64
+
65
+ it('rewrites a bare wikilink-style href (no extension) when the slug is a known doc', () => {
66
+ const input = '<a href="Services">Services</a>';
67
+ const out = rewriteDocLinks(input, known);
68
+ expect(out).toContain('href="Services.html"');
69
+ });
70
+
71
+ it('leaves unknown bare hrefs alone (could be an external path)', () => {
72
+ const input = '<a href="SomeUnknownPage">x</a>';
73
+ const out = rewriteDocLinks(input, new Set(['Services']));
74
+ expect(out).toContain('href="SomeUnknownPage"');
75
+ });
76
+
77
+ it('preserves an anchor fragment when rewriting', () => {
78
+ const input = '<a href="./Services.md#section">link</a>';
79
+ const out = rewriteDocLinks(input, known);
80
+ expect(out).toContain('href="Services.html#section"');
81
+ });
82
+
83
+ it('does not touch external (http/https) links', () => {
84
+ const input = '<a href="https://example.com/foo.md">external</a>';
85
+ const out = rewriteDocLinks(input, known);
86
+ expect(out).toContain('href="https://example.com/foo.md"');
87
+ });
88
+
89
+ it('does not touch pure-anchor (#section) links', () => {
90
+ const input = '<a href="#section">anchor</a>';
91
+ const out = rewriteDocLinks(input, known);
92
+ expect(out).toContain('href="#section"');
93
+ });
94
+
95
+ it('handles a trailing anchor on a bare wikilink-style href', () => {
96
+ const input = '<a href="Home#pages">Home</a>';
97
+ const out = rewriteDocLinks(input, known);
98
+ expect(out).toContain('href="Home.html#pages"');
99
+ });
100
+ });
101
+
102
+ // ============================================================================
103
+ // buildDocs — end-to-end render pipeline (against the real docs/ submodule)
104
+ // ============================================================================
105
+ describe('buildDocs against docs/ submodule', () => {
106
+ let outDir: string;
107
+
108
+ beforeEach(() => {
109
+ outDir = mkdtempSync(path.join(os.tmpdir(), 'render-docs-'));
110
+ });
111
+
112
+ afterEach(() => {
113
+ rmSync(outDir, { recursive: true, force: true });
114
+ });
115
+
116
+ it('throws a clear error when the docs source directory is missing', async () => {
117
+ await expect(buildDocs({ srcDir: path.join(outDir, 'nope'), outDir })).rejects.toThrow(
118
+ /docs.*missing|missing.*docs|submodule/iu,
119
+ );
120
+ });
121
+
122
+ it('throws a clear error when the docs source directory is present but empty', async () => {
123
+ // Mirrors the build-script guard: an empty `docs/` checkout (submodule
124
+ // init'd but not populated) must fail loudly with the recovery command,
125
+ // not silently produce a docs section with zero pages.
126
+ const empty = mkdirSync(path.join(outDir, 'empty-docs'), { recursive: true });
127
+ if (empty === undefined) {
128
+ throw new Error('test setup failed: could not create empty-docs dir');
129
+ }
130
+ await expect(buildDocs({ srcDir: empty, outDir })).rejects.toThrow(/empty|submodule/iu);
131
+ });
132
+
133
+ it('emits an index.html under the output directory', async () => {
134
+ await buildDocs({ srcDir: docsSrc, outDir });
135
+ const idx = path.join(outDir, 'index.html');
136
+ expect(existsSync(idx)).toBe(true);
137
+ expect(statSync(idx).size).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('emits one HTML file per markdown source (non-empty output directory)', async () => {
141
+ const result = await buildDocs({ srcDir: docsSrc, outDir });
142
+ expect(result.emitted.length).toBeGreaterThanOrEqual(20);
143
+ for (const f of result.emitted) {
144
+ expect(f.endsWith('.html')).toBe(true);
145
+ expect(existsSync(path.join(outDir, f))).toBe(true);
146
+ }
147
+ expect(readdirSync(outDir).filter((f) => f.endsWith('.html')).length).toBe(
148
+ result.emitted.length,
149
+ );
150
+ });
151
+
152
+ it('renders Services.html with an <h1> and at least one <code> block', async () => {
153
+ await buildDocs({ srcDir: docsSrc, outDir });
154
+ const html = readFileSync(path.join(outDir, 'Services.html'), 'utf8');
155
+ expect(html).toMatch(/<h1[^>]*>/);
156
+ expect(html).toMatch(/<pre[^>]*><code/);
157
+ // The raw markdown marker should not survive rendering.
158
+ expect(html).not.toMatch(/^# IRC Services Reference/m);
159
+ });
160
+
161
+ it('emits Home as the index (index.html)', async () => {
162
+ await buildDocs({ srcDir: docsSrc, outDir });
163
+ const html = readFileSync(path.join(outDir, 'index.html'), 'utf8');
164
+ // Home.md's "Welcome to the Wiki." lead-in should be present.
165
+ expect(html).toMatch(/Welcome to the Wiki/i);
166
+ });
167
+
168
+ it('rewrites intra-docs links to .html in the rendered output (no .md hrefs remain)', async () => {
169
+ await buildDocs({ srcDir: docsSrc, outDir });
170
+ const files = readdirSync(outDir).filter((f) => f.endsWith('.html'));
171
+ expect(files.length).toBeGreaterThan(0);
172
+ for (const f of files) {
173
+ const html = readFileSync(path.join(outDir, f), 'utf8');
174
+ // No intra-docs .md href may remain. (External https://…/foo.md is fine.)
175
+ const intraMd = html.match(/href="(?!https?:|mailto:|#)[^"]*\.md[^"]*"/g) ?? [];
176
+ expect(intraMd).toEqual([]);
177
+ }
178
+ });
179
+
180
+ it('emits a shared stylesheet (referenced by every page)', async () => {
181
+ await buildDocs({ srcDir: docsSrc, outDir });
182
+ const files = readdirSync(outDir).filter((f) => f.endsWith('.html'));
183
+ // Every page links the same stylesheet.
184
+ for (const f of files) {
185
+ const html = readFileSync(path.join(outDir, f), 'utf8');
186
+ expect(html).toMatch(/<link[^>]+rel=["']stylesheet["']/);
187
+ }
188
+ // And the stylesheet actually exists in the output.
189
+ const css = readdirSync(outDir).filter((f) => f.endsWith('.css'));
190
+ expect(css.length).toBeGreaterThanOrEqual(1);
191
+ });
192
+
193
+ it('emits an ADR-Index page that links each ADR via a rewritten .html href', async () => {
194
+ await buildDocs({ srcDir: docsSrc, outDir });
195
+ const html = readFileSync(path.join(outDir, 'ADR-Index.html'), 'utf8');
196
+ expect(html).toMatch(/ADR-001-pure-reducers-and-effect-system\.html/);
197
+ });
198
+ });
@@ -373,8 +373,9 @@ scheme (post-v1).
373
373
 
374
374
  │ all-or-nothing → races structurally impossible
375
375
 
376
-
377
- dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES
376
+
377
+ dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES,
378
+ and when a topic is set: 332 RPL_TOPIC, 333 RPL_TOPICWHOTIME
378
379
  ```
379
380
 
380
381
  A `CancellationException` from `TransactWriteItems` maps to the
@@ -530,21 +530,24 @@ namespaces).
530
530
 
531
531
  ## 8. Secrets
532
532
 
533
- **As of v1 the Lambda consumes no Secrets-Manager/SSM bindings.** Server
534
- password and oper creds are handled by later hardening work; when they
535
- ship, the natural homes are AWS Secrets Manager or SSM Parameter Store
536
- (the AWS adapter architecture doc calls both out as supporting
537
- services). SASL account credentials, however, **are** persisted in the
538
- `Accounts` DynamoDB table (see §8.1).
533
+ **The Lambda consumes one optional secret: `SERVER_PASSWORD`.** When
534
+ set (as a Lambda env var, conventionally sourced from AWS Secrets
535
+ Manager or SSM Parameter Store), every connection must supply the
536
+ matching `PASS <value>` (or SASL-identify) before `001 RPL_WELCOME`;
537
+ when unset/empty the gate is disabled (the default). The adapter's
538
+ config loader reads `process.env.SERVER_PASSWORD` at cold start via
539
+ `loadServerConfigFromLambdaEnv` and threads it through to
540
+ `ServerConfig.serverPassword`.
539
541
 
540
- When secrets land, the typical pattern will be:
542
+ The typical provisioning pattern:
541
543
 
542
544
  1. Create the secret in Secrets Manager (or a `SecureString`
543
545
  parameter in SSM).
544
546
  2. Grant the Lambda role `secretsmanager:GetSecretValue` (or
545
547
  `ssm:GetParameter`) scoped to that one secret ARN.
546
548
  3. Read it inside `buildDepsFromEnv` (cold start) — never inside the
547
- per-frame hot path.
549
+ per-frame hot path — and expose the value to the Lambda env as
550
+ `SERVER_PASSWORD`.
548
551
 
549
552
  ```bash
550
553
  aws secretsmanager create-secret \
@@ -552,6 +555,16 @@ aws secretsmanager create-secret \
552
555
  --secret-string "$(openssl rand -base64 32)"
553
556
  ```
554
557
 
558
+ **SASL short-circuit:** a connection that has authenticated an account
559
+ via SASL (`AUTHENTICATE PLAIN` / `EXTERNAL`) is exempt from the server
560
+ password — the reducer's gate treats `state.account !== undefined` as
561
+ already authorised. Oper creds (`OPER_USER` / `OPER_PASSWORD`) follow
562
+ the same secret pattern; SASL account credentials are **not** secrets —
563
+ they are persisted in the `Accounts` DynamoDB table (see §8.1). **Never
564
+ log `SERVER_PASSWORD`** — treat it as a shared deployment secret,
565
+ distinct from per-account SASL credentials (which go through the hashed
566
+ `Accounts` table).
567
+
555
568
  ### 8.1 SASL accounts (`Accounts` table + seed tooling)
556
569
 
557
570
  SASL PLAIN credentials live in the `Accounts` DynamoDB table as
@@ -365,11 +365,11 @@ managed separately in the dashboard.
365
365
 
366
366
  ## 8. Secrets
367
367
 
368
- **As of v1 the Worker consumes no secret bindings.** Server password and
369
- oper creds land in later tickets (TICKET-046 / TICKET-047). SASL account
370
- credentials are **not** secrets they are persisted as scrypt hashes in
371
- D1 (see §8.1 below). When server password / oper secrets ship, they will
372
- be set via:
368
+ **The Worker consumes one optional secret binding: `SERVER_PASSWORD`.**
369
+ When set, every connection must supply the matching `PASS <value>` (or
370
+ SASL-identify) before `001 RPL_WELCOME`; when unset/empty the gate is
371
+ disabled (the default). Configure it as a wrangler secret, never a
372
+ plaintext `[vars]` entry:
373
373
 
374
374
  ```bash
375
375
  wrangler secret put SERVER_PASSWORD --env staging
@@ -377,10 +377,26 @@ wrangler secret put SERVER_PASSWORD --env staging
377
377
  # NOT in wrangler.toml or the repo.
378
378
  ```
379
379
 
380
+ Unset (`wrangler secret delete SERVER_PASSWORD`) disables the gate.
381
+ **SASL short-circuit:** a connection that has authenticated an account
382
+ via SASL (`AUTHENTICATE PLAIN` / `EXTERNAL`) is exempt from the
383
+ server password — the reducer's gate treats `state.account !== undefined`
384
+ as already authorised, so a deployment with both `SERVER_PASSWORD` and
385
+ configured SASL accounts does not need to hand the shared password to
386
+ identified users.
387
+
388
+ Oper creds (`OPER_USER` / `OPER_PASSWORD`) are still on the roadmap;
389
+ when they ship they will follow the same wrangler-secret pattern. SASL
390
+ account credentials are **not** secrets — they are persisted as scrypt
391
+ hashes in D1 (see §8.1 below).
392
+
380
393
  Secrets are visible to the Worker as plain `env.SECRET_NAME` bindings,
381
394
  but their values are never written to disk, the bundle, or the
382
395
  dashboard's variable listing. To rotate, run `wrangler secret put`
383
- again; to remove, `wrangler secret delete <NAME>`.
396
+ again; to remove, `wrangler secret delete <NAME>`. **Never log
397
+ `SERVER_PASSWORD`** — treat it as a shared deployment secret, distinct
398
+ from per-account SASL credentials (which go through the hashed D1
399
+ store).
384
400
 
385
401
  **Never** put credentials, API tokens, or password hashes in
386
402
  `wrangler.toml`, in `[vars]`, or in committed files. The
@@ -253,9 +253,30 @@ connection is not identified as. The dispatch layer resolves it against
253
253
  Already-present seams the services reuse:
254
254
 
255
255
  - `AccountStore.verify('PLAIN', ...)` (TICKET-026 ✅) → a successful
256
- NickServ `IDENTIFY` and a SASL PLAIN login both reduce to "this
257
- connection is now account X", setting the `+r` user mode and
258
- emitting `ACCOUNT` to `account-notify`-capable peers.
256
+ NickServ `IDENTIFY`, a SASL PLAIN login, and a PASS-based account login
257
+ (TICKET-152 ✅) all reduce to "this connection is now account X". The
258
+ shared `applyAccountSuccess(state, ctx, account)` helper stamps the
259
+ account, sets the `+r` user mode, restores per-account state (read
260
+ markers, persisted away, queued memos), and emits `900 RPL_LOGGEDIN` +
261
+ `903 RPL_SASLSUCCESS` plus the `ACCOUNT` broadcast to
262
+ `account-notify`-capable peers.
263
+ - **PASS-based account auth (TICKET-152 ✅)** — a third account-login
264
+ entry point alongside SASL and NickServ `IDENTIFY`. A client that sends
265
+ `NICK <nick>` then `PASS <nick>:<password>` (then `USER`) is identified
266
+ to `ctx.accounts` at registration completion without speaking SASL. The
267
+ `<nick>:<password>` literal matches the `SASL_ACCOUNTS` seed format. The
268
+ payload nick (left of the first `:`) is the account name and MUST equal
269
+ `state.nick` at registration completion. Order of precedence at
270
+ registration is: (1) already identified (SASL/PASS-auth) → gate
271
+ satisfied; (2) `<nick>:<password>` verifies against `ctx.accounts` →
272
+ account set; (3) bare `PASS` matches `serverPassword` → gate satisfied;
273
+ (4) else `464 ERR_PASSWDMISMATCH` + disconnect. A bare `PASS <value>`
274
+ (no colon) is always a server-password candidate, never an account
275
+ credential; when `ctx.accounts` is unbound the `<nick>:<password>` form
276
+ is ignored and behaviour collapses to the server-password gate. Like
277
+ SASL, PASS-auth runs through `applyAccountSuccess` and therefore stamps
278
+ `+r`; a failed verify reveals nothing — unknown nick and wrong password
279
+ are indistinguishable (a fixed dummy verify equalises the failure path).
259
280
  - `MessageStore` (TICKET-063 ✅) → MemoServ's `SEND` is a record into
260
281
  a per-account memo queue; delivery is a fanout at identify time.
261
282
  - `clock` / `IdFactory` ports → memo ids, enforcement grace timers.
@@ -464,6 +485,52 @@ follows strict TDD (Red → Green → Refactor) per `CLAUDE.md` /
464
485
  `seedReadMarkers`; `CHATHISTORY BEFORE` with no pivot falls back to the
465
486
  connection's marker. TICKET-122 should fold this store into the unified
466
487
  services `UserStore` when it lands.
488
+ - **Divergence vs. spec compliance (TICKET-150 follow-up, shipped):**
489
+ TICKET-118 shipped **channel-wide** mark TAGMSG fanout for pragmatic parity
490
+ with the `draft/typing` precedent — every `message-tags` OR
491
+ `draft/read-marker` peer in the channel received the mark. The ircv3
492
+ `draft/read-marker` spec, however, mandates that a read marker **MUST NOT be
493
+ disclosed to other users**: a client's own mark is private to its account.
494
+ The Phase 14 follow-up (account-scoped `draft/read-marker` fanout) restored
495
+ spec compliance with two behaviour changes:
496
+
497
+ 1. **Identified originator → account-scoped fanout.** The
498
+ `tagmsgChannelReducer` now annotates the mark `TAGMSG` Broadcast effect
499
+ with the originator's `account`. The dispatch layer's cap-aware
500
+ `broadcast()` restricts the recipient set to channel members
501
+ authenticated as that same account (RFC 1459 case-folded comparison),
502
+ still honouring `except` (originator skip) and the OR-semantics `caps`
503
+ filter (`message-tags` OR `draft/read-marker`). Other accounts in the
504
+ channel never receive the mark; the originator's other live sessions on
505
+ the same account (multi-device sync, when they share the channel)
506
+ receive it. `filterClientTags` and its `+draft/read-marker` whitelist
507
+ are unchanged — only the *recipient set* changes.
508
+ 2. **Unidentified originator → no fanout (session-local).** An
509
+ unidentified connection (no account) emits **no** Broadcast for a read-
510
+ marker mark. Its mark stays session-local (the in-memory
511
+ `lastReadMarkers` still advances; persistence is a no-op without an
512
+ account, unchanged from TICKET-118). The originator's own client still
513
+ observes its mark via the `echo-message` self-echo. This matches the
514
+ spec's privacy default: a read marker never reaches *other* users, and
515
+ an unidentified connection has no account to share a mark with.
516
+
517
+ **Recipient-resolution primitive ("or equivalent" index).** The ticket's
518
+ description floated an `AccountConnections` index on the runtime. The
519
+ shipped implementation takes the spec-permitted "(or equivalent)" path:
520
+ there is **no separate index structure**. The recipient set is resolved at
521
+ dispatch time by walking the channel roster and consulting each member's
522
+ live `ConnectionState.account` via the existing `getConnectionInfo` port
523
+ method (the same per-recipient resolution already used for cap gating).
524
+ This is always fresh by construction — disconnected connections leave the
525
+ roster, and account changes are live references — so the staleness
526
+ concerns an explicit index would need (re-sync on `ACCOUNT`/SASL/
527
+ disconnect) do not apply. Global cross-channel account fanout (reaching an
528
+ account's sessions that are *not* in the marked channel) is a documented
529
+ follow-up, not needed for spec privacy: the mark is channel-specific, and
530
+ the persistence layer (`ServicesStore.setLastReadMarker`) already syncs a
531
+ session's marker on reconnect/rejoin via `seedReadMarkers`. 100% coverage
532
+ on the account-scoped fanout + dispatch filter (irc-core and irc-server
533
+ both enforce a 100% gate).
467
534
 
468
535
  #### TICKET-119 — IRCv3 `draft/pre-away` (persist away status)
469
536
  - **Phase:** 12
@@ -613,6 +680,49 @@ follows strict TDD (Red → Green → Refactor) per `CLAUDE.md` /
613
680
  - **TDD Outline:** Red on REGISTER→`+r`; green; then `+R`/`+M`;
614
681
  then KEEPTOPIC; then MLOCK.
615
682
 
683
+ Implementation notes (post-land):
684
+ - Three new boolean channel modes added to `ChannelModes`:
685
+ `registered` (`+r`), `blockUnidentified` (`+R`), `moderatedIdent`
686
+ (`+M`). They appear in the `324 RPL_CHANNELMODEIS` reply when set,
687
+ but `channelModeReducer` rejects `+r`/`+R`/`+M` via `MODE` with `472
688
+ ERR_UNKNOWNMODE` — only ChanServ may toggle them via
689
+ `ApplyChannelDelta` effects.
690
+ - `519 ERR_ALLMUSTREGISTER` added to `Numerics` for the `+R` JOIN gate.
691
+ - `ServicesStore` extended: `getChannel`, `setChannelFounder`,
692
+ `setChannelMlock`/`getChannelMlock`, `setChannelRestricted`,
693
+ `setChannelKeepTopic`, `setChannelTopic`/`getChannelTopic`. The
694
+ `RegisteredChannel` record now carries `mlock` / `restricted` /
695
+ `keepTopic` / `topic?`. The in-memory store returns defensive
696
+ copies (callers cannot mutate the store by editing returned
697
+ records).
698
+ - `chanservReducer` (new, `commands/chanserv.ts`) handles
699
+ `PRIVMSG ChanServ :<command>`. Subcommands: `REGISTER`, `DROP`,
700
+ `INFO`, `SET FOUNDER/MLOCK/RESTRICTED/KEEPTOPIC`. Channel
701
+ mutations are emitted as `ApplyChannelDelta` effects so the actor
702
+ applies them to the authoritative `ChannelState`.
703
+ - `SET RESTRICTED ON|OFF` toggles the `+R` channel mode (and stores
704
+ the preference). `SET KEEPTOPIC ON|OFF` toggles topic persistence
705
+ and drops any previously-stored snapshot when turned off.
706
+ - JOIN reducer consults services on the first join after empty-recreate
707
+ to restore a KEEPTOPIC snapshot (emits `332`/`333` to the joiner).
708
+ `+R` JOIN gate rejects unidentified clients with `519`.
709
+ - TOPIC reducer persists the snapshot on every change (set or clear)
710
+ when KEEPTOPIC is on.
711
+ - PRIVMSG/NOTICE channel reducers enforce `+M` (op/voice/identified
712
+ pass) and `+R` (identified pass) with `404`; NOTICE is silent.
713
+ - `channelModeReducer` reapplies MLOCK after every applied MODE: any
714
+ positively-locked boolean mode that ended up unset (whether by this
715
+ MODE or by prior state) is re-asserted via a ChanServ-emitted
716
+ `ApplyChannelDelta` + `Broadcast` (`:ChanServ!ChanServ@services
717
+ MODE #chan +…`). Defence in depth — services-only modes (`+r`) are
718
+ also defended, so the registered mark survives even if a future
719
+ code path applies an `ApplyChannelDelta` clearing it.
720
+ - Actor routes `PRIVMSG ChanServ` (case-insensitive) to
721
+ `reducers.chanserv`; NOTICE and unbound-services cases fall through
722
+ to the normal user-target reducer (RFC: NOTICE is silent).
723
+ - 100% coverage on `chanserv.ts`, `mode.ts` (reapply branch), and the
724
+ new join/privmsg/topic branches.
725
+
616
726
  #### TICKET-126 — ChanServ: ACCESS / LEVELS (SOP/AOP/VOP/HOP)
617
727
  - **Phase:** 13
618
728
  - **Points:** 5
@@ -15,8 +15,8 @@ opening a PR that bumps versions and updates `CHANGELOG.md`; the
15
15
  maintainer team cuts the tag.
16
16
 
17
17
  Cross-reference: `README.md` (Roadmap), `progress.md` / `tickets.md`
18
- (release content), `docs/deployment-cf.md` (prod deploy step §6),
19
- `.github/workflows/ci.yml`, `.github/workflows/deploy-cf.yml`.
18
+ (release content), `docs/Cloudflare-Deployment-Guide.md` (prod deploy
19
+ step §6), `.github/workflows/ci.yml`, `.github/workflows/deploy-cf.yml`.
20
20
 
21
21
  ---
22
22
 
@@ -37,7 +37,7 @@ this repo itself) should pin exact versions.
37
37
 
38
38
  ### 1.1 Workspace version lock
39
39
 
40
- All eight `package.json` files (root + 7 workspace packages) carry the
40
+ All fifteen `package.json` files (root + 14 workspace packages) carry the
41
41
  **same** version string. They are bumped together in the release
42
42
  commit. The pre-release checklist in §4 enforces this.
43
43
 
@@ -132,17 +132,20 @@ node apps/cf-worker/scripts/smoke.mjs \
132
132
  If any box fails, **stop**. Fix forward on `main` and restart the
133
133
  checklist — do not tag over a broken tip.
134
134
 
135
- ### 4.1 What is explicitly out of scope for a 0.x release
135
+ ### 4.1 What is explicitly out of scope for the 0.x line
136
136
 
137
- - AWS adapter (TICKET-039 through TICKET-044).
138
- - SASL authentication (TICKET-026).
139
- - Flood control (TICKET-028).
140
- - Server password / rate limits / cloaking (TICKET-046).
141
- - Config loader (TICKET-047).
137
+ Items still pending in `progress.md` that are **not** blockers for a
138
+ given release they ship when ready and are called out under "Known
139
+ limitations" in the GitHub Release body:
142
140
 
143
- These belong in a later 0.x release. Their absence is **not** a
144
- blocker and should be called out under "Known limitations" in the
145
- GitHub Release body.
141
+ - Client compatibility sweep (WeeChat / HexChat / IRCCloud / TheLounge /
142
+ matrix-IRC bridge) `progress.md` TICKET-050.
143
+ - Playwright headless-browser e2e for the web client — TICKET-138.
144
+ - Driving the sub-100% packages to full coverage — TICKET-143 through
145
+ TICKET-148.
146
+
147
+ Their absence is **not** a blocker. If a release intentionally defers an
148
+ in-progress ticket, name it under "Known limitations".
146
149
 
147
150
  ---
148
151
 
@@ -184,7 +187,7 @@ understand what changed and what they need to do.
184
187
 
185
188
  ## 6. Bumping versions
186
189
 
187
- There are eight `package.json` files. They must end up at the same
190
+ There are fifteen `package.json` files. They must end up at the same
188
191
  version:
189
192
 
190
193
  ```
@@ -192,10 +195,17 @@ version:
192
195
  packages/irc-core/package.json
193
196
  packages/irc-server/package.json
194
197
  packages/in-memory-runtime/package.json
198
+ packages/irc-test-support/package.json
195
199
  packages/cf-adapter/package.json
200
+ packages/aws-adapter/package.json
196
201
  apps/local-cli/package.json
197
202
  apps/cf-worker/package.json
203
+ apps/cf-tcp-container/package.json
204
+ apps/aws-stack/package.json
205
+ apps/web/package.json
198
206
  tools/tcp-ws-forwarder/package.json
207
+ tools/load-test/package.json
208
+ tools/ci-hardening/package.json
199
209
  ```
200
210
 
201
211
  To cut `v0.X.Y`: