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.
- package/.github/workflows/ci.yml +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- 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 `
|
|
36
|
-
// worker's fall-through routing is exercised without depending on
|
|
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 /
|
|
65
|
-
const response = await worker.fetch(new Request('https://irc.example.com/
|
|
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 `
|
|
18
|
-
# fall-through routing (`/health` → plaintext, else → ASSETS) is
|
|
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 `/
|
|
24
|
-
# landing page at `/`, both with real `index.html` files, so no SPA
|
|
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
|
-
## • `/
|
|
76
|
-
## hashed assets under
|
|
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 `/
|
|
90
|
-
## page at `/`; both are concrete `index.html` files, so no SPA
|
|
91
|
-
## required. Kiwi uses query/hash-based client routing, not path
|
|
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`)
|
|
@@ -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 `/
|
|
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>
|
|
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
|
-
|
|
182
|
-
<strong>Cloudflare Workers</strong> (Durable Objects)
|
|
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>
|
|
189
|
-
<li>
|
|
190
|
-
<li>
|
|
191
|
-
<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="/
|
|
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://
|
|
201
|
-
<a href="https://
|
|
202
|
-
<a href="/
|
|
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 ·
|
|
211
|
-
<a href="https://
|
|
209
|
+
<a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Gitea</a> ·
|
|
212
210
|
BSD-3-Clause License
|
|
213
211
|
</p>
|
|
214
212
|
</div>
|
package/apps/web/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "Web client workspace: Kiwi IRC SPA served 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, '
|
|
12
|
+
const appDist = path.join(dist, 'webclient');
|
|
13
13
|
const staticDir = path.join(pkgRoot, 'static');
|
|
14
14
|
|
|
15
|
-
// The SPA is served at /
|
|
16
|
-
//
|
|
17
|
-
// from /
|
|
18
|
-
const PUBLIC_PATH = '/
|
|
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
|
-
|
|
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=/
|
|
54
|
-
// temporarily patch vue.config.js so webpack emits /
|
|
55
|
-
// URLs and the runtime public_path matches the mount point. Restored
|
|
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/
|
|
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 /
|
|
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 /
|
|
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/
|
|
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/
|
|
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/
|
|
8
|
-
* fails the build with a readable aggregated error rather than
|
|
9
|
-
* broken SPA. The schema only models the fields ServerlessIRCd
|
|
10
|
-
* (server, port, channel, WS path); the rest of Kiwi's surface
|
|
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 `/
|
|
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', '
|
|
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/
|
|
23
|
-
it('emits dist/
|
|
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/
|
|
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 /
|
|
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 /
|
|
43
|
-
const appPrefixed = html.match(/(?:src|href)=["']\/
|
|
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/
|
|
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 /
|
|
68
|
+
it('links to the IRC client at /webclient/', () => {
|
|
69
69
|
const html = readFileSync(landingSrc, 'utf8');
|
|
70
|
-
expect(html).toMatch(/href=["']\/
|
|
70
|
+
expect(html).toMatch(/href=["']\/webclient\/?["']/);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it('
|
|
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(/
|
|
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: '/
|
|
253
|
+
startupOptions: validStartupOptions({ direct_path: '/webclient/' }),
|
|
254
254
|
}),
|
|
255
255
|
),
|
|
256
256
|
).toThrowError(/direct_path/u);
|
package/docs/AWS-Deployment.md
CHANGED
|
@@ -530,21 +530,24 @@ namespaces).
|
|
|
530
530
|
|
|
531
531
|
## 8. Secrets
|
|
532
532
|
|
|
533
|
-
**
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
`
|
|
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
|
-
|
|
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
|
-
**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|