serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  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-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -32,9 +32,9 @@ declare global {
32
32
  MOTD_LINES: string;
33
33
  // Static-assets binding (`[assets]` in `wrangler.test.toml`, pointed at
34
34
  // `tests/fixtures/web-dist`). Production points at `apps/web/dist`; the
35
- // fixture mirrors the `app/index.html` + `index.html` layout so the
36
- // worker's fall-through routing is exercised without depending on the
37
- // Kiwi SPA build.
35
+ // fixture mirrors the `webclient/index.html` + `index.html` layout so
36
+ // the worker's fall-through routing is exercised without depending on
37
+ // the Kiwi SPA build.
38
38
  ASSETS: Fetcher;
39
39
  }
40
40
  }
@@ -61,8 +61,8 @@ describe('cf-worker deploy glue', () => {
61
61
  expect(body.toLowerCase()).toContain('serverlessircd');
62
62
  });
63
63
 
64
- it('GET /app/ serves the SPA index from the [assets] binding', async () => {
65
- const response = await worker.fetch(new Request('https://irc.example.com/app/'), env);
64
+ it('GET /webclient/ serves the SPA index from the [assets] binding', async () => {
65
+ const response = await worker.fetch(new Request('https://irc.example.com/webclient/'), env);
66
66
  expect(response.status).toBe(200);
67
67
  const body = await response.text();
68
68
  // The fixture SPA HTML carries this marker so the test asserts the
@@ -14,15 +14,15 @@ compatibility_flags = ["nodejs_compat"]
14
14
  # Static-assets binding. Production points at `../../apps/web/dist` (the Kiwi
15
15
  # SPA + landing page built by `apps/web`); the test config points at a fixture
16
16
  # so the smoke suite does not depend on the (slow) Kiwi upstream build. The
17
- # fixture mirrors the `app/index.html` + `index.html` layout; the worker's
18
- # fall-through routing (`/health` → plaintext, else → ASSETS) is what's under
19
- # test, not the SPA contents.
17
+ # fixture mirrors the `webclient/index.html` + `index.html` layout; the
18
+ # worker's fall-through routing (`/health` → plaintext, else → ASSETS) is
19
+ # what's under test, not the SPA contents.
20
20
  [assets]
21
21
  directory = "./tests/fixtures/web-dist"
22
22
  binding = "ASSETS"
23
- # `none` returns 404 for unmatched paths — the SPA lives at `/app/` and the
24
- # landing page at `/`, both with real `index.html` files, so no SPA fallback
25
- # is required.
23
+ # `none` returns 404 for unmatched paths — the SPA lives at `/webclient/` and
24
+ # the landing page at `/`, both with real `index.html` files, so no SPA
25
+ # fallback is required.
26
26
  not_found_handling = "none"
27
27
  # Mirror production: route every request through the Worker first so WS
28
28
  # upgrades at `/` aren't intercepted by the asset platform (which would
@@ -72,8 +72,9 @@ This is a deployed Cloudflare Worker.
72
72
  ## The `[assets]` binding serves `apps/web/dist` (built by `pnpm --filter web
73
73
  ## build`). The non-WS, non-`/health` branch of `worker.ts`'s `fetch` falls
74
74
  ## through to this binding, so:
75
- ## • `/app/` → the Kiwi IRC SPA (`dist/app/index.html` + its
76
- ## hashed assets under `/app/assets/...`).
75
+ ## • `/webclient/` → the Kiwi IRC SPA (`dist/webclient/index.html`
76
+ ## + its hashed assets under
77
+ ## `/webclient/assets/...`).
77
78
  ## • `/` → the landing page (`dist/index.html`, shipped by
78
79
  ## the `apps/web` build).
79
80
  ## • `/health` → plaintext liveness (handled by the worker, not
@@ -86,9 +87,10 @@ This is a deployed Cloudflare Worker.
86
87
  directory = "../../apps/web/dist"
87
88
  ## Expose the asset `Fetcher` on `env.ASSETS` (see `WorkerEnv` in `worker.ts`).
88
89
  binding = "ASSETS"
89
- ## Return 404 for unmatched paths. The SPA lives at `/app/` and the landing
90
- ## page at `/`; both are concrete `index.html` files, so no SPA fallback is
91
- ## required. Kiwi uses query/hash-based client routing, not path routing.
90
+ ## Return 404 for unmatched paths. The SPA lives at `/webclient/` and the
91
+ ## landing page at `/`; both are concrete `index.html` files, so no SPA
92
+ ## fallback is required. Kiwi uses query/hash-based client routing, not path
93
+ ## routing.
92
94
  not_found_handling = "none"
93
95
  ## Route EVERY request through the Worker before the asset platform serves a
94
96
  ## matching static file. Without this, the default (`run_worker_first = false`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -35,6 +35,14 @@ export interface CliConfigInput {
35
35
  channelPrefixes?: string;
36
36
  maxClients?: number;
37
37
  operCreds?: Array<{ user: string; password: string }>;
38
+ /**
39
+ * Server-password gate. Treat as a secret; the loader threads it
40
+ * straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
41
+ * so the registration reducer requires `PASS <value>` (or a SASL-
42
+ * identified connection) before `001 RPL_WELCOME`. Undefined disables
43
+ * the gate.
44
+ */
45
+ serverPassword?: string | undefined;
38
46
  maxChannelsPerUser?: number;
39
47
  maxTargetsPerCommand?: number;
40
48
  maxListEntries?: number;
@@ -35,6 +35,12 @@ interface CliArgs {
35
35
  serverName: string;
36
36
  /** Network name advertised in 005 NETWORK=... */
37
37
  networkName: string;
38
+ /**
39
+ * Server-password gate. Undefined disables the gate; when set every
40
+ * connection must supply the matching `PASS <value>` (or SASL-identify)
41
+ * before `001 RPL_WELCOME`.
42
+ */
43
+ serverPassword: string | undefined;
38
44
  }
39
45
 
40
46
  const DEFAULT_PORT = 6667;
@@ -49,6 +55,7 @@ function parseArgs(argv: string[]): CliArgs {
49
55
  motdFile: undefined,
50
56
  serverName: DEFAULT_SERVER_NAME,
51
57
  networkName: DEFAULT_NETWORK_NAME,
58
+ serverPassword: undefined,
52
59
  };
53
60
  let tcpDisabled = false;
54
61
  let tcpPortOverride: number | undefined;
@@ -73,6 +80,9 @@ function parseArgs(argv: string[]): CliArgs {
73
80
  } else if (a === '--network-name' && next !== undefined) {
74
81
  out.networkName = next;
75
82
  i++;
83
+ } else if (a === '--server-password' && next !== undefined) {
84
+ out.serverPassword = next;
85
+ i++;
76
86
  } else if (a === '--no-tcp') {
77
87
  tcpDisabled = true;
78
88
  } else if (a === '--help' || a === '-h') {
@@ -92,6 +102,11 @@ function parseArgs(argv: string[]): CliArgs {
92
102
  ' Optional; defaults to irc.localhost for the local CLI.',
93
103
  ' --network-name <n> Network name in 005 NETWORK=...',
94
104
  ' Optional; defaults to LocalNet.',
105
+ ' --server-password <p>',
106
+ ' Server-password gate (optional). When set, every',
107
+ ' connection must supply the matching PASS <p> (or',
108
+ ' SASL-identify) before 001 RPL_WELCOME. Treat as a',
109
+ ' secret; do not log it.',
95
110
  ' -h, --help Show this help and exit.',
96
111
  '',
97
112
  ].join('\n'),
@@ -119,6 +134,7 @@ async function main(): Promise<void> {
119
134
  tcpPort: args.tcpPort,
120
135
  serverName: args.serverName,
121
136
  networkName: args.networkName,
137
+ ...(args.serverPassword !== undefined ? { serverPassword: args.serverPassword } : {}),
122
138
  ...(motdLines !== undefined ? { motdLines } : {}),
123
139
  });
124
140
 
@@ -302,6 +302,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
302
302
  ...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
303
303
  ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
304
304
  ...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
305
+ ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
305
306
  });
306
307
  } catch (err) {
307
308
  return Promise.reject(err);
@@ -55,6 +55,20 @@ describe('loadServerConfigFromCliArgs — minimal input', () => {
55
55
  expect(cfg.maxClients).toBe(50);
56
56
  expect(cfg.operCreds).toEqual([{ user: 'op', password: 'p' }]);
57
57
  });
58
+
59
+ it('threads serverPassword through to the parsed config', () => {
60
+ const cfg = loadServerConfigFromCliArgs({
61
+ serverName: 's',
62
+ networkName: 'n',
63
+ serverPassword: 'hunter2',
64
+ });
65
+ expect(cfg.serverPassword).toBe('hunter2');
66
+ });
67
+
68
+ it('leaves serverPassword undefined when not supplied', () => {
69
+ const cfg = loadServerConfigFromCliArgs({ serverName: 's', networkName: 'n' });
70
+ expect(cfg.serverPassword).toBeUndefined();
71
+ });
58
72
  });
59
73
 
60
74
  describe('loadServerConfigFromCliArgs — failure modes', () => {
@@ -4,8 +4,8 @@
4
4
 
5
5
  Served at `/` by the Worker `[assets]` binding (apps/cf-worker/wrangler.toml).
6
6
  The build (`scripts/build.mjs`) copies this file verbatim to `dist/index.html`.
7
- The Kiwi IRC SPA lives at `/app/`; this page links through to it plus the
8
- project docs and source repo. Pure static HTML + inline CSS, no JavaScript.
7
+ The Kiwi IRC SPA lives at `/webclient/`; this page links through to it plus
8
+ the project docs and source repo. Pure static HTML + inline CSS, no JavaScript.
9
9
  -->
10
10
  <html lang="en">
11
11
  <head>
@@ -170,7 +170,7 @@
170
170
  <header>
171
171
  <div class="container">
172
172
  <h1>Serverless<span class="irc">IRCd</span></h1>
173
- <p>A serverless IRC daemon. One TypeScript core, two serverless substrates.</p>
173
+ <p>An IRC daemon with a serverless deployment model.</p>
174
174
  </div>
175
175
  </header>
176
176
 
@@ -178,28 +178,26 @@
178
178
  <div class="container">
179
179
  <p>
180
180
  ServerlessIRCd is an IRC server where the protocol logic lives in a
181
- pure, platform-agnostic core, and two thin adapters run it on
182
- <strong>Cloudflare Workers</strong> (Durable Objects) or
181
+ platform-agnostic TypeScript core. Two adapters run that core on
182
+ <strong>Cloudflare Workers</strong> (Durable Objects) and
183
183
  <strong>AWS</strong> (API Gateway WebSockets + Lambda + DynamoDB).
184
- The same code, unchanged, on both.
185
184
  </p>
186
185
 
187
186
  <ul class="features">
188
- <li>Pure-reducer protocol core every command is a trivial unit test</li>
189
- <li>Dual transport: WebSocket <em>and</em> <code>irc+tls</code> on port 6697</li>
190
- <li>Full IRCv3 surface: message-tags, chathistory, SASL, labeled-response, and more</li>
191
- <li>Authoritative state in one place per datum — races are structurally impossible</li>
187
+ <li>Reducer-based protocol core: each command is a pure function</li>
188
+ <li>WebSocket and <code>irc+tls</code> (port 6697) transports</li>
189
+ <li>IRCv3 extensions: message-tags, chathistory, SASL, labeled-response</li>
190
+ <li>Per-datum authoritative state held in a single location</li>
192
191
  </ul>
193
192
 
194
193
  <div class="cta">
195
- <a class="primary" href="/app/">Launch Web Client</a>
196
- <a class="secondary" href="docs/">Read the Docs</a>
194
+ <a class="primary" href="/webclient/">Launch Web Client</a>
197
195
  </div>
198
196
 
199
197
  <div class="links">
200
- <a href="https://github.com/fowlmouth/ServerlessIRCd">Source Code (GitHub)</a>
201
- <a href="https://github.com/fowlmouth/ServerlessIRCd/issues">Issue Tracker</a>
202
- <a href="/app/">Web IRC Client</a>
198
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Source Code (Gitea)</a>
199
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/issues">Issue Tracker</a>
200
+ <a href="/webclient/">Web IRC Client</a>
203
201
  </div>
204
202
  </div>
205
203
  </main>
@@ -208,7 +206,7 @@
208
206
  <div class="container">
209
207
  <p>
210
208
  ServerlessIRCd &middot;
211
- <a href="https://github.com/fowlmouth/ServerlessIRCd">GitHub</a> &middot;
209
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Gitea</a> &middot;
212
210
  BSD-3-Clause License
213
211
  </p>
214
212
  </div>
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
- "description": "Web client workspace: Kiwi IRC SPA served at /app/ and project landing page at /",
5
+ "description": "Web client workspace: Kiwi IRC SPA served at /webclient/ and project landing page at /",
6
6
  "license": "BSD-3-Clause",
7
7
  "type": "module",
8
8
  "scripts": {
@@ -9,13 +9,13 @@ import { parseKiwiConfig } from '../src/config-schema.ts';
9
9
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
10
  const upstream = path.resolve(pkgRoot, 'upstream');
11
11
  const dist = path.resolve(pkgRoot, 'dist');
12
- const appDist = path.join(dist, 'app');
12
+ const appDist = path.join(dist, 'webclient');
13
13
  const staticDir = path.join(pkgRoot, 'static');
14
14
 
15
- // The SPA is served at /app/ on the Worker (the [assets] binding arrives in a
16
- // later ticket). All hashed asset URLs must be /app/-prefixed so they load
17
- // from /app/static/... rather than /static/... (which would 404 at root).
18
- const PUBLIC_PATH = '/app/';
15
+ // The SPA is served at /webclient/ on the Worker (the [assets] binding serves
16
+ // `dist/`). All hashed asset URLs must be /webclient/-prefixed so they load
17
+ // from /webclient/static/... rather than /static/... (which would 404 at root).
18
+ const PUBLIC_PATH = '/webclient/';
19
19
 
20
20
  // Yarn runs from a pnpm lifecycle script. Kiwi v1.7.1's package.json has no
21
21
  // `packageManager` field, so yarn classic walks up and finds the workspace
@@ -45,15 +45,21 @@ if (!existsSync(upstream)) {
45
45
  }
46
46
 
47
47
  // 1. Install upstream deps. Kiwi ships a yarn.lock; --frozen-lockfile gives a
48
- // reproducible tree. Contributors get yarn via `corepack enable`.
49
- if (!existsSync(path.join(upstream, 'node_modules'))) {
48
+ // reproducible tree. Contributors get yarn via `corepack enable`. The
49
+ // guard checks for the actual binary the build invokes, not just the
50
+ // `node_modules` directory — a stale/partial install (interrupted, or
51
+ // populated by a foreign package manager that skipped `.bin/` linking)
52
+ // leaves the directory present but `vue-cli-service` unresolvable, which
53
+ // would otherwise crash the build at step 2 with a confusing exit 127.
54
+ const vueCliBin = path.join(upstream, 'node_modules', '.bin', 'vue-cli-service');
55
+ if (!existsSync(vueCliBin)) {
50
56
  runYarn(['install', '--frozen-lockfile', '--ignore-engines']);
51
57
  }
52
58
 
53
- // 2. Build with publicPath=/app/. Upstream pins publicPath:'' (relative); we
54
- // temporarily patch vue.config.js so webpack emits /app/-prefixed asset
55
- // URLs and the runtime public_path matches the mount point. Restored in
56
- // `finally` so the submodule stays clean.
59
+ // 2. Build with publicPath=/webclient/. Upstream pins publicPath:'' (relative);
60
+ // we temporarily patch vue.config.js so webpack emits /webclient/-prefixed
61
+ // asset URLs and the runtime public_path matches the mount point. Restored
62
+ // in `finally` so the submodule stays clean.
57
63
  const vueConfigPath = path.join(upstream, 'vue.config.js');
58
64
  const originalVueConfig = await readFile(vueConfigPath, 'utf8');
59
65
  try {
@@ -67,14 +73,15 @@ try {
67
73
  await writeFile(vueConfigPath, originalVueConfig);
68
74
  }
69
75
 
70
- // 3. Copy the built SPA into dist/app/.
76
+ // 3. Copy the built SPA into dist/webclient/.
71
77
  await rm(appDist, { recursive: true, force: true });
72
78
  await mkdir(appDist, { recursive: true });
73
79
  await cp(path.join(upstream, 'dist'), appDist, { recursive: true });
74
80
 
75
81
  // 3b. Webpack applies publicPath to the bundles it emits, but template-hardcoded
76
82
  // refs (e.g. the favicon) stay relative. Prefix every bare `static/...`
77
- // reference so all asset URLs resolve to /app/static/... when served at /app/.
83
+ // reference so all asset URLs resolve to /webclient/static/... when served
84
+ // at /webclient/.
78
85
  const indexDest = path.join(appDist, 'index.html');
79
86
  let indexHtml = await readFile(indexDest, 'utf8');
80
87
  indexHtml = indexHtml.replace(/((?:src|href)=["'])(static\/)/g, `$1${PUBLIC_PATH}$2`);
@@ -109,8 +116,8 @@ await writeFile(
109
116
 
110
117
  // 5. Landing page: copy the static landing page (landing/index.html) to
111
118
  // dist/index.html so the Worker `[assets]` binding serves it at `/`. Pure
112
- // static HTML + inline CSS (no JS); links to /app/ (the SPA), docs/, and
113
- // the source repo.
119
+ // static HTML + inline CSS (no JS); links to /webclient/ (the SPA), docs/,
120
+ // and the source repo.
114
121
  await mkdir(dist, { recursive: true });
115
122
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
116
123
  if (!existsSync(landingSrc)) {
@@ -121,5 +128,5 @@ if (!existsSync(landingSrc)) {
121
128
  await cp(landingSrc, path.join(dist, 'index.html'));
122
129
 
123
130
  console.log(
124
- `Kiwi SPA built -> dist/app/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
131
+ `Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
125
132
  );
@@ -4,7 +4,7 @@
4
4
  * Extracted from the build script so the `--env` → config-file mapping is
5
5
  * unit-testable without running the (slow) Kiwi upstream build. The build
6
6
  * script delegates here and then validates the selected file against
7
- * {@link parseKiwiConfig} before baking it into `dist/app/static/config.json`.
7
+ * {@link parseKiwiConfig} before baking it into `dist/webclient/static/config.json`.
8
8
  */
9
9
 
10
10
  /** argv values accepted by `--env`; mapped to a `static/config.<env>.json` file. */
@@ -4,16 +4,16 @@
4
4
  * supply to drive the embedded Kiwi SPA.
5
5
  *
6
6
  * `build.mjs` runs {@link parseKiwiConfig} against the selected env config
7
- * before baking it into `dist/app/static/config.json`, so a malformed config
8
- * fails the build with a readable aggregated error rather than shipping a
9
- * broken SPA. The schema only models the fields ServerlessIRCd cares about
10
- * (server, port, channel, WS path); the rest of Kiwi's surface is passed
11
- * through untouched.
7
+ * before baking it into `dist/webclient/static/config.json`, so a malformed
8
+ * config fails the build with a readable aggregated error rather than
9
+ * shipping a broken SPA. The schema only models the fields ServerlessIRCd
10
+ * cares about (server, port, channel, WS path); the rest of Kiwi's surface
11
+ * is passed through untouched.
12
12
  *
13
13
  * Hosting layout reminder:
14
14
  * - The Worker's WebSocket endpoint is at `/` (root), so Kiwi's
15
15
  * `startupOptions.path` — when supplied — MUST be `/`.
16
- * - The SPA itself is served from `/app/` by the Worker `[assets]`
16
+ * - The SPA itself is served from `/webclient/` by the Worker `[assets]`
17
17
  * binding (a separate concern handled by the public-path patch in
18
18
  * `build.mjs`); that is independent of the WS path modelled here.
19
19
  */
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest';
5
5
  import { parseKiwiConfig } from '../src/config-schema';
6
6
 
7
7
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
- const appDist = path.join(pkgRoot, 'dist', 'app');
8
+ const appDist = path.join(pkgRoot, 'dist', 'webclient');
9
9
  const indexPath = path.join(appDist, 'index.html');
10
10
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
11
11
  const landingDist = path.join(pkgRoot, 'dist', 'index.html');
@@ -19,12 +19,12 @@ const built = existsSync(indexPath);
19
19
  // artifact; gate the build-output assertion on its own presence.
20
20
  const landingBuilt = existsSync(landingDist) && statSync(landingDist).size > 0;
21
21
 
22
- describe.skipIf(!built)('Kiwi SPA build output (dist/app/)', () => {
23
- it('emits dist/app/index.html', () => {
22
+ describe.skipIf(!built)('Kiwi SPA build output (dist/webclient/)', () => {
23
+ it('emits dist/webclient/index.html', () => {
24
24
  expect(existsSync(indexPath)).toBe(true);
25
25
  });
26
26
 
27
- it('layers a schema-valid static/config.json into dist/app/static/config.json', () => {
27
+ it('layers a schema-valid static/config.json into dist/webclient/static/config.json', () => {
28
28
  const configPath = path.join(appDist, 'static', 'config.json');
29
29
  expect(existsSync(configPath)).toBe(true);
30
30
  // The baked config must clear the Zod schema no matter which --env was
@@ -34,17 +34,17 @@ describe.skipIf(!built)('Kiwi SPA build output (dist/app/)', () => {
34
34
  expect(cfg.startupOptions.channel.startsWith('#')).toBe(true);
35
35
  });
36
36
 
37
- it('prefixes every asset reference with /app/', () => {
37
+ it('prefixes every asset reference with /webclient/', () => {
38
38
  const html = readFileSync(indexPath, 'utf8');
39
39
  // No bare relative `static/...` references may remain.
40
40
  const bareStaticRefs = html.match(/(?:src|href)=["']static\//g) ?? [];
41
41
  expect(bareStaticRefs).toEqual([]);
42
- // At least one asset must be /app/-prefixed (the main bundle).
43
- const appPrefixed = html.match(/(?:src|href)=["']\/app\/static\//g) ?? [];
42
+ // At least one asset must be /webclient/-prefixed (the main bundle).
43
+ const appPrefixed = html.match(/(?:src|href)=["']\/webclient\/static\//g) ?? [];
44
44
  expect(appPrefixed.length).toBeGreaterThan(0);
45
45
  });
46
46
 
47
- it('ships a hashed JS bundle under dist/app/static/js/', () => {
47
+ it('ships a hashed JS bundle under dist/webclient/static/js/', () => {
48
48
  const jsDir = path.join(appDist, 'static', 'js');
49
49
  expect(existsSync(jsDir)).toBe(true);
50
50
  const js = readdirSync(jsDir).filter((f) => f.endsWith('.js'));
@@ -65,19 +65,19 @@ describe('landing page source (landing/index.html)', () => {
65
65
  expect(html).toContain('<title>');
66
66
  });
67
67
 
68
- it('links to the IRC client at /app/', () => {
68
+ it('links to the IRC client at /webclient/', () => {
69
69
  const html = readFileSync(landingSrc, 'utf8');
70
- expect(html).toMatch(/href=["']\/app\/?["']/);
70
+ expect(html).toMatch(/href=["']\/webclient\/?["']/);
71
71
  });
72
72
 
73
- it('links to the project docs', () => {
73
+ it('does not link to the project docs (docs button temporarily removed)', () => {
74
74
  const html = readFileSync(landingSrc, 'utf8');
75
- expect(html).toMatch(/href=["'][^"']*docs\/?["']/);
75
+ expect(html).not.toMatch(/href=["'][^"']*docs\/?["']/);
76
76
  });
77
77
 
78
- it('links to the source repository', () => {
78
+ it('links to the source repository on Gitea', () => {
79
79
  const html = readFileSync(landingSrc, 'utf8');
80
- expect(html.toLowerCase()).toMatch(/github\.com/);
80
+ expect(html.toLowerCase()).toMatch(/gitea\.com/);
81
81
  });
82
82
 
83
83
  it('is responsive (declares a viewport meta tag)', () => {
@@ -250,7 +250,7 @@ describe('KiwiConfigSchema — WS path (direct_path)', () => {
250
250
  expect(() =>
251
251
  parseKiwiConfig(
252
252
  validConfig({
253
- startupOptions: validStartupOptions({ direct_path: '/app/' }),
253
+ startupOptions: validStartupOptions({ direct_path: '/webclient/' }),
254
254
  }),
255
255
  ),
256
256
  ).toThrowError(/direct_path/u);
@@ -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