serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
|
|
30
30
|
import { DurableObject } from 'cloudflare:workers';
|
|
31
31
|
import {
|
|
32
|
-
type AccountStore,
|
|
33
32
|
type ChanName,
|
|
34
33
|
type ChannelState,
|
|
35
34
|
type Clock,
|
|
@@ -46,8 +45,8 @@ import {
|
|
|
46
45
|
type MotdProvider,
|
|
47
46
|
type MtlsIdentityProvider,
|
|
48
47
|
type NickHistoryStore,
|
|
48
|
+
type ParsedServerConfig,
|
|
49
49
|
type RawLine,
|
|
50
|
-
type SaslAccountCredential,
|
|
51
50
|
type ServerConfig,
|
|
52
51
|
SystemClock,
|
|
53
52
|
UuidIdFactory,
|
|
@@ -66,7 +65,6 @@ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
|
66
65
|
import { makeCfRuntime } from './cf-runtime.js';
|
|
67
66
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
68
67
|
import { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
69
|
-
import { resolveAccountStore } from './d1-account-store.js';
|
|
70
68
|
import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
|
|
71
69
|
import type { Env } from './env.js';
|
|
72
70
|
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
@@ -100,9 +98,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
100
98
|
* Cached reducer-facing server config. Parsed once from `this.env`
|
|
101
99
|
* (bindings are stable for the DO's lifetime) so the schema-validated
|
|
102
100
|
* `loadServerConfigFromCfEnv` runs a single time rather than on every
|
|
103
|
-
* WebSocket frame.
|
|
101
|
+
* WebSocket frame. Held as {@link ParsedServerConfig} so the adapter
|
|
102
|
+
* can read extra fields (e.g. `saslAccounts` for the services seed)
|
|
103
|
+
* the reducer-facing `ServerConfig` view omits.
|
|
104
104
|
*/
|
|
105
|
-
private
|
|
105
|
+
private cachedParsedConfig: ParsedServerConfig | undefined;
|
|
106
106
|
/**
|
|
107
107
|
* Per-instance channel-state cache. Reused across every WebSocket frame
|
|
108
108
|
* the connection handles (within a single hibernation cycle) so that
|
|
@@ -142,25 +142,14 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
142
142
|
* keyed by nick) is a documented follow-up. Lazily constructed.
|
|
143
143
|
*/
|
|
144
144
|
private history: NickHistoryStore | undefined;
|
|
145
|
-
/**
|
|
146
|
-
* SASL account store, resolved once via {@link loadAccountStore} before the
|
|
147
|
-
* first frame dispatches. The two-phase load mirrors AWS's
|
|
148
|
-
* `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
|
|
149
|
-
* rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
|
|
150
|
-
* env-var seed provides the fallback `InMemoryAccountStore`. The
|
|
151
|
-
* `AccountStore.verify` port is synchronous, so pre-loading MUST complete
|
|
152
|
-
* before the first reducer runs.
|
|
153
|
-
*/
|
|
154
|
-
private accounts: AccountStore | undefined;
|
|
155
|
-
/** Guards {@link loadAccountStore} so the D1 query runs at most once. */
|
|
156
|
-
private accountsResolved = false;
|
|
157
145
|
/**
|
|
158
146
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
159
147
|
* OperServ + read-marker), hydrated once via {@link loadServicesStore}
|
|
160
148
|
* before the first frame dispatches. The store mutates the in-memory
|
|
161
149
|
* cache synchronously and enqueues write-behind ops; {@link flushServices}
|
|
162
150
|
* drains them into D1 after each frame / at teardown. `undefined` when
|
|
163
|
-
* D1 is not configured (services unbound → no NickServ/ChanServ routing
|
|
151
|
+
* D1 is not configured (services unbound → no NickServ/ChanServ routing,
|
|
152
|
+
* no SASL/PASS credential verify).
|
|
164
153
|
*/
|
|
165
154
|
private services: D1ServicesStore | undefined;
|
|
166
155
|
/** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
|
|
@@ -218,14 +207,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
218
207
|
const pair = new WebSocketPair();
|
|
219
208
|
const server = pair[0];
|
|
220
209
|
const client = pair[1];
|
|
221
|
-
|
|
222
|
-
// active. `request.cf` is a Cloudflare-specific extension; the property
|
|
223
|
-
// is present only when a client certificate was verified against the
|
|
224
|
-
// uploaded CA pool.
|
|
225
|
-
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
226
|
-
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
227
|
-
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
228
|
-
}
|
|
210
|
+
this.captureMtlsSubject(request);
|
|
229
211
|
// Capture the client source IP for ConnectionState.host. CF-Connecting-IP
|
|
230
212
|
// is set by the Cloudflare edge on every proxied request; X-Real-IP is
|
|
231
213
|
// the conventional fallback for non-CF reverse proxies. Stashed on the
|
|
@@ -283,7 +265,6 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
283
265
|
const state = await this.loadState();
|
|
284
266
|
const beforeNick = state.nick;
|
|
285
267
|
|
|
286
|
-
await this.loadAccountStore();
|
|
287
268
|
await this.loadServicesStore();
|
|
288
269
|
const actor = this.buildActor(ws, state, mode);
|
|
289
270
|
try {
|
|
@@ -388,17 +369,39 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
388
369
|
* socket count without sending anything. Used by ChannelDO.sweep.
|
|
389
370
|
*/
|
|
390
371
|
async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
|
|
372
|
+
return this.deliverToSockets(this.ctx.getWebSockets() as unknown as WebSocket[], lines);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Iterates `sockets`, delivering `lines` to each OPEN one and counting
|
|
377
|
+
* the writes. Extracted from {@link deliver} so the false arm of the
|
|
378
|
+
* tryDeliver check is testable with a fake non-OPEN socket — the real
|
|
379
|
+
* `ctx.getWebSockets()` only returns OPEN sockets in the test runtime.
|
|
380
|
+
*/
|
|
381
|
+
public deliverToSockets(sockets: WebSocket[], lines: RawLine[]): { delivered: number } {
|
|
391
382
|
let delivered = 0;
|
|
392
|
-
for (const ws of
|
|
393
|
-
|
|
394
|
-
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
395
|
-
const mode = this.wsFrameMode(socket);
|
|
396
|
-
this.sendOutbound(socket, lines, mode);
|
|
397
|
-
delivered++;
|
|
383
|
+
for (const ws of sockets) {
|
|
384
|
+
delivered += this.tryDeliver(ws, lines) ? 1 : 0;
|
|
398
385
|
}
|
|
399
386
|
return { delivered };
|
|
400
387
|
}
|
|
401
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Delivers `lines` to a single attached WebSocket. Returns `false`
|
|
391
|
+
* (without writing) when the socket is no longer OPEN, `true` after a
|
|
392
|
+
* successful write. Public so the readyState guard is testable with a
|
|
393
|
+
* fake socket — the real `ctx.getWebSockets()` only returns OPEN
|
|
394
|
+
* sockets in the test runtime, but production may briefly surface
|
|
395
|
+
* CLOSING sockets between the unilateral-close and the close event
|
|
396
|
+
* reaching the DO.
|
|
397
|
+
*/
|
|
398
|
+
public tryDeliver(ws: WebSocket, lines: RawLine[]): boolean {
|
|
399
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
400
|
+
const mode = this.wsFrameMode(ws);
|
|
401
|
+
this.sendOutbound(ws, lines, mode);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
402
405
|
// -------------------------------------------------------------------------
|
|
403
406
|
// Internal helpers
|
|
404
407
|
// -------------------------------------------------------------------------
|
|
@@ -441,6 +444,17 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
441
444
|
ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
|
|
442
445
|
}
|
|
443
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Test-only seam that exposes {@link sendOutbound} so the readyState
|
|
449
|
+
* early-return is reachable with a fake non-OPEN socket. Production
|
|
450
|
+
* callers always pass an OPEN socket (the {@link deliver} and
|
|
451
|
+
* {@link tryDeliver} guards filter first), so the path cannot be
|
|
452
|
+
* exercised via real RPC.
|
|
453
|
+
*/
|
|
454
|
+
public __sendOutboundForTest(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
|
|
455
|
+
this.sendOutbound(ws, lines, mode);
|
|
456
|
+
}
|
|
457
|
+
|
|
444
458
|
/** Loads the connection state from storage, caching for the event. */
|
|
445
459
|
private async loadState(): Promise<ConnectionState> {
|
|
446
460
|
if (this.cached !== undefined) return this.cached;
|
|
@@ -484,10 +498,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
484
498
|
this.sendOutbound(ws, lines, mode);
|
|
485
499
|
},
|
|
486
500
|
disconnect: (reason?: string): void => {
|
|
487
|
-
|
|
488
|
-
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
489
|
-
}
|
|
490
|
-
ws.close();
|
|
501
|
+
this.writeDisconnect(ws, mode, reason);
|
|
491
502
|
},
|
|
492
503
|
snapshot: (): ConnectionState | undefined => this.cached,
|
|
493
504
|
};
|
|
@@ -503,7 +514,6 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
503
514
|
// Bound `connectionId` so every record the actor emits is filterable
|
|
504
515
|
// per-connection in the Workers dashboard.
|
|
505
516
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
506
|
-
const accounts = this.accountStore();
|
|
507
517
|
const services = this.servicesStore();
|
|
508
518
|
const mtlsIdentity: MtlsIdentityProvider | undefined =
|
|
509
519
|
this.mtlsCertSubject !== undefined
|
|
@@ -529,17 +539,48 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
529
539
|
// Cloudflare terminates TLS at the edge before the WebSocket reaches
|
|
530
540
|
// the DO, so every WS connection is secure → user mode `S`.
|
|
531
541
|
secure: true,
|
|
532
|
-
...(
|
|
533
|
-
...(services !== undefined ? { services } : {}),
|
|
534
|
-
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
542
|
+
...buildOptionalActorFields(services, mtlsIdentity),
|
|
535
543
|
logger,
|
|
536
544
|
});
|
|
537
545
|
}
|
|
538
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Writes the optional `ERROR :Closing link` line and closes the socket.
|
|
549
|
+
* Extracted from the {@link buildActor} disconnect callback so the
|
|
550
|
+
* reason-gated branch is testable without driving a full actor flow
|
|
551
|
+
* that emits a self-Disconnect with a reason (rare — OperServ GHOST,
|
|
552
|
+
* KILL, flood, bad server-password).
|
|
553
|
+
*/
|
|
554
|
+
private writeDisconnect(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
|
|
555
|
+
if (reason !== undefined) {
|
|
556
|
+
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
557
|
+
}
|
|
558
|
+
ws.close();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Test-only seam that exposes {@link closeIfStillOpen} so the
|
|
563
|
+
* readyState-guarded `ws.close()` is reachable with a fake OPEN
|
|
564
|
+
* socket. The real QUIT flow always closes the WS via the actor's
|
|
565
|
+
* self-Disconnect handler before tearDown reaches this guard.
|
|
566
|
+
*/
|
|
567
|
+
public __closeIfStillOpenForTest(ws: WebSocket): void {
|
|
568
|
+
this.closeIfStillOpen(ws);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Test-only seam that exposes {@link writeDisconnect} so the
|
|
573
|
+
* reason-gated `ERROR :Closing link` write is reachable without
|
|
574
|
+
* driving a full actor flow that emits a self-Disconnect with a
|
|
575
|
+
* reason (rare — OperServ GHOST, KILL, flood, bad server-password).
|
|
576
|
+
*/
|
|
577
|
+
public __writeDisconnectForTest(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
|
|
578
|
+
this.writeDisconnect(ws, mode, reason);
|
|
579
|
+
}
|
|
580
|
+
|
|
539
581
|
/** Tears down a connection: emit QUIT effects, release nick, close WS. */
|
|
540
582
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
541
583
|
const state = await this.loadState();
|
|
542
|
-
await this.loadAccountStore();
|
|
543
584
|
await this.loadServicesStore();
|
|
544
585
|
const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
|
|
545
586
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
@@ -547,9 +588,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
547
588
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
548
589
|
await this.releaseAndPersist(state);
|
|
549
590
|
await this.flushServices();
|
|
550
|
-
|
|
551
|
-
ws.close();
|
|
552
|
-
}
|
|
591
|
+
this.closeIfStillOpen(ws);
|
|
553
592
|
try {
|
|
554
593
|
await this.ctx.storage.deleteAlarm();
|
|
555
594
|
} catch {
|
|
@@ -557,6 +596,21 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
557
596
|
}
|
|
558
597
|
}
|
|
559
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Closes `ws` when it is still in a state where calling `close()` is
|
|
601
|
+
* meaningful (OPEN or CONNECTING). The QUIT flow drives a self-Disconnect
|
|
602
|
+
* that already closes the socket, so by the time {@link tearDown} reaches
|
|
603
|
+
* this guard the WS is usually CLOSING/CLOSED; the helper is a defensive
|
|
604
|
+
* backstop for the rare path where the actor does not emit a self-
|
|
605
|
+
* Disconnect. Extracted as a public seam so both arms are testable with
|
|
606
|
+
* a fake socket — the real QUIT flow always hits the false arm.
|
|
607
|
+
*/
|
|
608
|
+
private closeIfStillOpen(ws: WebSocket): void {
|
|
609
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
610
|
+
ws.close();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
560
614
|
/** Releases the nick via the registry and persists the (possibly mutated) state. */
|
|
561
615
|
private async releaseAndPersist(state: ConnectionState): Promise<void> {
|
|
562
616
|
if (state.nick !== undefined) {
|
|
@@ -577,19 +631,24 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
577
631
|
// silently dropped fields like operCreds, which left OPER unable to
|
|
578
632
|
// authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
|
|
579
633
|
// were bound. See config-loader.ts and config.ts.
|
|
580
|
-
|
|
581
|
-
|
|
634
|
+
return this.parsedConfig();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Returns the full parsed config (the schema-validated superset of
|
|
639
|
+
* `ServerConfig`). The adapter reads fields the reducer-facing
|
|
640
|
+
* `ServerConfig` omits — `saslAccounts` (services seed), `motdLines`
|
|
641
|
+
* (MOTD provider), `maxClients` (admission), …
|
|
642
|
+
*/
|
|
643
|
+
private parsedConfig(): ParsedServerConfig {
|
|
644
|
+
if (this.cachedParsedConfig === undefined) {
|
|
645
|
+
this.cachedParsedConfig = loadServerConfigFromCfEnv(this.env);
|
|
582
646
|
}
|
|
583
|
-
return this.
|
|
647
|
+
return this.cachedParsedConfig;
|
|
584
648
|
}
|
|
585
649
|
|
|
586
650
|
private motdProvider(): MotdProvider {
|
|
587
|
-
|
|
588
|
-
if (raw === undefined || raw.length === 0) {
|
|
589
|
-
return { lines: () => DEFAULT_MOTD };
|
|
590
|
-
}
|
|
591
|
-
// MOTD_LINES is a newline-delimited env var.
|
|
592
|
-
return { lines: () => raw.split('\n') };
|
|
651
|
+
return chooseMotdProvider(this.env.MOTD_LINES);
|
|
593
652
|
}
|
|
594
653
|
|
|
595
654
|
/**
|
|
@@ -617,41 +676,32 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
617
676
|
return this.history;
|
|
618
677
|
}
|
|
619
678
|
|
|
620
|
-
/**
|
|
621
|
-
* Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
|
|
622
|
-
* caching the result. Safe to call on every frame; the D1 query runs at
|
|
623
|
-
* most once per ConnectionDO instance. Must complete before the first
|
|
624
|
-
* reducer dispatches so the synchronous `AccountStore.verify` port has its
|
|
625
|
-
* data ready.
|
|
626
|
-
*/
|
|
627
|
-
private async loadAccountStore(): Promise<void> {
|
|
628
|
-
if (this.accountsResolved) return;
|
|
629
|
-
this.accountsResolved = true;
|
|
630
|
-
this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Returns the resolved account store, or `undefined` when no accounts are
|
|
635
|
-
* configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
|
|
636
|
-
* PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
|
|
637
|
-
* `await loadAccountStore()` before reading this.
|
|
638
|
-
*/
|
|
639
|
-
private accountStore(): AccountStore | undefined {
|
|
640
|
-
return this.accounts;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
679
|
/**
|
|
644
680
|
* Resolves the persistent services store once (D1 → write-behind cache),
|
|
645
681
|
* caching the result. Safe to call on every frame; the D1 scan runs at
|
|
646
682
|
* most once per ConnectionDO instance. Must complete before the first
|
|
647
683
|
* reducer dispatches so the synchronous `ServicesStore` port has its
|
|
648
684
|
* snapshot ready. Leaves `services` `undefined` when D1 is absent so the
|
|
649
|
-
* actor's `ctx.services` stays unset (no NickServ/ChanServ routing
|
|
685
|
+
* actor's `ctx.services` stays unset (no NickServ/ChanServ routing,
|
|
686
|
+
* no SASL/PASS credential verify).
|
|
687
|
+
*
|
|
688
|
+
* After the D1 load, the `SASL_ACCOUNTS` env-var seed is ingested:
|
|
689
|
+
* each `username:password` pair is registered via `registerNick` when
|
|
690
|
+
* the nick is not already present. Idempotent — re-ingesting on every
|
|
691
|
+
* cold start is a no-op for nicks the services table already holds
|
|
692
|
+
* (including NickServ-registered nicks whose password wins). New
|
|
693
|
+
* env-seed accounts are persisted by the write-behind flush.
|
|
650
694
|
*/
|
|
651
695
|
private async loadServicesStore(): Promise<void> {
|
|
652
696
|
if (this.servicesResolved) return;
|
|
653
697
|
this.servicesResolved = true;
|
|
654
698
|
this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
|
|
699
|
+
if (this.services !== undefined) {
|
|
700
|
+
const cfg = this.parsedConfig();
|
|
701
|
+
for (const acct of cfg.saslAccounts) {
|
|
702
|
+
this.services.registerNick(acct.username, acct.password, '');
|
|
703
|
+
}
|
|
704
|
+
}
|
|
655
705
|
}
|
|
656
706
|
|
|
657
707
|
/**
|
|
@@ -674,6 +724,18 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
674
724
|
await this.services.flush();
|
|
675
725
|
}
|
|
676
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Test-only seam that exposes {@link flushServices} so the
|
|
729
|
+
* "services unbound" early-return is reachable. Production always
|
|
730
|
+
* drives flushServices after `loadServicesStore()` has run (in
|
|
731
|
+
* `webSocketMessage` and `tearDown*`), so the field is populated;
|
|
732
|
+
* the undefined arm fires only on a DO that has never processed a
|
|
733
|
+
* frame.
|
|
734
|
+
*/
|
|
735
|
+
public async __flushServicesForTest(): Promise<void> {
|
|
736
|
+
await this.flushServices();
|
|
737
|
+
}
|
|
738
|
+
|
|
677
739
|
// -------------------------------------------------------------------------
|
|
678
740
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
679
741
|
// -------------------------------------------------------------------------
|
|
@@ -699,13 +761,12 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
699
761
|
private async tearDownNoSocket(state: ConnectionState): Promise<void> {
|
|
700
762
|
// Emit QUIT effects without dispatching transport calls (the socket
|
|
701
763
|
// is already gone). The cross-DO fanout still runs.
|
|
702
|
-
await this.loadAccountStore();
|
|
703
764
|
await this.loadServicesStore();
|
|
704
765
|
const services = this.servicesStore();
|
|
705
766
|
const noOpHandlers: CfConnectionHandlers = {
|
|
706
|
-
send:
|
|
707
|
-
disconnect:
|
|
708
|
-
snapshot: ()
|
|
767
|
+
send: NO_OP_SEND,
|
|
768
|
+
disconnect: NO_OP_DISCONNECT,
|
|
769
|
+
snapshot: this.peekCachedSync.bind(this),
|
|
709
770
|
};
|
|
710
771
|
const registryKey = this.ctx.id.toString();
|
|
711
772
|
const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
|
|
@@ -723,7 +784,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
723
784
|
motd: this.motdProvider(),
|
|
724
785
|
messages: this.messageStore(),
|
|
725
786
|
history: this.historyStore(),
|
|
726
|
-
...(services
|
|
787
|
+
...buildOptionalActorFields(services, undefined),
|
|
727
788
|
});
|
|
728
789
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
729
790
|
await this.releaseAndPersist(state);
|
|
@@ -735,6 +796,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
735
796
|
}
|
|
736
797
|
}
|
|
737
798
|
|
|
799
|
+
/** Synchronous cached-state peek used by the no-socket teardown handlers. */
|
|
800
|
+
private peekCachedSync(): ConnectionState | undefined {
|
|
801
|
+
return this.cached;
|
|
802
|
+
}
|
|
803
|
+
|
|
738
804
|
/** Drops the in-memory state cache, simulating wake-from-hibernation. */
|
|
739
805
|
public __resetCache(): void {
|
|
740
806
|
this.cached = undefined;
|
|
@@ -742,7 +808,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
742
808
|
|
|
743
809
|
/** Returns the live cached state (without forcing a storage read). */
|
|
744
810
|
public async __peekState(): Promise<ConnectionState | undefined> {
|
|
745
|
-
return this.
|
|
811
|
+
return this.peekCachedSync();
|
|
746
812
|
}
|
|
747
813
|
|
|
748
814
|
/** Backdates `lastSeen` to test the alarm-driven disconnect path. */
|
|
@@ -761,6 +827,58 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
761
827
|
this.__suppressCloseTeardown = true;
|
|
762
828
|
}
|
|
763
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Test-only: sets the captured mTLS cert subject, simulating an upgrade
|
|
832
|
+
* request that arrived through CF API Shield mTLS. Used to exercise the
|
|
833
|
+
* `mtlsIdentity` construction branch in {@link buildActor} without
|
|
834
|
+
* having to inject `request.cf` (which is read-only in the workerd
|
|
835
|
+
* test runtime).
|
|
836
|
+
*/
|
|
837
|
+
/**
|
|
838
|
+
* Captures the verified mTLS cert subject off an inbound request and
|
|
839
|
+
* stores it on the instance. Extracted so the assignment branch is
|
|
840
|
+
* testable via {@link __captureMtlsSubjectForTest} (the workerd test
|
|
841
|
+
* runtime strips `request.cf` across the DO RPC boundary, so the
|
|
842
|
+
* inline fetch path cannot be exercised end-to-end).
|
|
843
|
+
*/
|
|
844
|
+
private captureMtlsSubject(request: Request): void {
|
|
845
|
+
const cf = extractMtlsSubject(request);
|
|
846
|
+
if (cf !== undefined) {
|
|
847
|
+
this.mtlsCertSubject = cf;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
public __setMtlsCertSubject(subject: string | undefined): void {
|
|
852
|
+
this.mtlsCertSubject = subject;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Test-only: returns the captured mTLS cert subject (or undefined). */
|
|
856
|
+
public async __peekMtlsCertSubject(): Promise<string | undefined> {
|
|
857
|
+
return this.mtlsCertSubject;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Test-only seam that exposes {@link captureMtlsSubject} so the
|
|
862
|
+
* if-defined assignment can be exercised with a crafted request.cf.
|
|
863
|
+
*/
|
|
864
|
+
public __captureMtlsSubjectForTest(request: Request): void {
|
|
865
|
+
this.captureMtlsSubject(request);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Test-only: invokes {@link webSocketError} against the first attached
|
|
870
|
+
* WebSocket. The platform normally fires this asynchronously on
|
|
871
|
+
* transport errors; calling it here deterministically exercises the
|
|
872
|
+
* handler body. The WebSocket must have been accepted via `fetch` so
|
|
873
|
+
* `ctx.getWebSockets()` returns it.
|
|
874
|
+
*/
|
|
875
|
+
public async __triggerWsError(error: unknown): Promise<void> {
|
|
876
|
+
const sockets = this.ctx.getWebSockets();
|
|
877
|
+
const ws = sockets[0];
|
|
878
|
+
if (ws === undefined) return;
|
|
879
|
+
await this.webSocketError(ws as WebSocket, error);
|
|
880
|
+
}
|
|
881
|
+
|
|
764
882
|
/** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
|
|
765
883
|
public __peekHexId(): string {
|
|
766
884
|
return this.ctx.id.toString();
|
|
@@ -813,6 +931,70 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
813
931
|
/** Default MOTD when no `MOTD_LINES` env is supplied. */
|
|
814
932
|
const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
|
|
815
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Reads the verified mTLS client-cert subject off an inbound Request, when
|
|
936
|
+
* CF API Shield mTLS is configured. Extracted as a pure helper so the
|
|
937
|
+
* `cf.tlsClientAuthCertSubject` branch is unit-testable directly (the
|
|
938
|
+
* workerd test runtime does not surface `request.cf` the way the CF edge
|
|
939
|
+
* does, and `Request.cf` is read-only so tests cannot inject it via the
|
|
940
|
+
* standard constructor). Returns the subject string when present, or
|
|
941
|
+
* `undefined` when mTLS is not configured.
|
|
942
|
+
*/
|
|
943
|
+
export function extractMtlsSubject(request: Request): string | undefined {
|
|
944
|
+
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
945
|
+
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
946
|
+
return cf.tlsClientAuthCertSubject;
|
|
947
|
+
}
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* No-op transport callbacks used by {@link ConnectionDO.tearDownNoSocket}
|
|
953
|
+
* when the WebSocket has already been dropped. Exported so the function
|
|
954
|
+
* bodies are exercised by a direct unit test (the actor never invokes
|
|
955
|
+
* `send`/`disconnect` during the no-socket QUIT path because there is
|
|
956
|
+
* no peer to deliver to).
|
|
957
|
+
*/
|
|
958
|
+
export const NO_OP_SEND: (lines: RawLine[]) => void = () => {};
|
|
959
|
+
export const NO_OP_DISCONNECT: (reason?: string) => void = () => {};
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Builds the optional `services` / `mtlsIdentity` fields for a
|
|
963
|
+
* {@link ConnectionActor} constructor. Each field is included only when
|
|
964
|
+
* the source resolved a value; the `: {}` arms of the conditional spreads
|
|
965
|
+
* are exercised by both the with-everything and with-nothing test paths.
|
|
966
|
+
*
|
|
967
|
+
* Extracted as a pure helper so the conditional-spread branches are
|
|
968
|
+
* testable without driving a full DO event cycle that leaves the
|
|
969
|
+
* underlying stores unset (the test env binds D1 + services, so production
|
|
970
|
+
* builds always hit the defined arm for `services`).
|
|
971
|
+
*/
|
|
972
|
+
export function buildOptionalActorFields(
|
|
973
|
+
services: D1ServicesStore | undefined,
|
|
974
|
+
mtlsIdentity: MtlsIdentityProvider | undefined,
|
|
975
|
+
): {
|
|
976
|
+
services?: D1ServicesStore;
|
|
977
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
978
|
+
} {
|
|
979
|
+
return {
|
|
980
|
+
...(services !== undefined ? { services } : {}),
|
|
981
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Resolves the {@link MotdProvider} for a raw `MOTD_LINES` env value.
|
|
987
|
+
* Returns the default single-line MOTD when the env is unset or empty;
|
|
988
|
+
* otherwise splits the env on newlines. Extracted so both branches are
|
|
989
|
+
* unit-testable without changing the bound env in the test worker.
|
|
990
|
+
*/
|
|
991
|
+
export function chooseMotdProvider(raw: string | undefined): MotdProvider {
|
|
992
|
+
if (raw === undefined || raw.length === 0) {
|
|
993
|
+
return { lines: () => DEFAULT_MOTD };
|
|
994
|
+
}
|
|
995
|
+
return { lines: () => raw.split('\n') };
|
|
996
|
+
}
|
|
997
|
+
|
|
816
998
|
/**
|
|
817
999
|
* `ActorChannelAccess` backed by a per-instance cache that is refreshed
|
|
818
1000
|
* from the authoritative ChannelDO before each reducer that needs to
|
|
@@ -827,7 +1009,7 @@ const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapte
|
|
|
827
1009
|
* synchronous reducers can mutate `members` (a Map) and `banMasks` /
|
|
828
1010
|
* `pendingInvites` (Sets) in place.
|
|
829
1011
|
*/
|
|
830
|
-
class PassthroughChannelAccess {
|
|
1012
|
+
export class PassthroughChannelAccess {
|
|
831
1013
|
private readonly cache = new Map<string, ChannelState>();
|
|
832
1014
|
constructor(
|
|
833
1015
|
private readonly now: number,
|
|
@@ -879,11 +1061,23 @@ class PassthroughChannelAccess {
|
|
|
879
1061
|
createdAt: number;
|
|
880
1062
|
} | null;
|
|
881
1063
|
if (dto === null) return;
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
1064
|
+
// When the authoritative ChannelDO reports zero members (the
|
|
1065
|
+
// post-deploy state), don't replace the cache with an empty roster —
|
|
1066
|
+
// the local reducer should lazily seed memberships via
|
|
1067
|
+
// getOrCreateChannel. But DO propagate the persisted topic / modes /
|
|
1068
|
+
// ban list onto an existing cache entry so the JOIN reducer sees the
|
|
1069
|
+
// survived topic and emits 332/333. Mirrors the AWS adapter's
|
|
1070
|
+
// LambdaChannelAccess.refreshChannel empty-members branch.
|
|
1071
|
+
if (dto.members.length === 0) {
|
|
1072
|
+
const existing = this.cache.get(key);
|
|
1073
|
+
if (existing !== undefined) {
|
|
1074
|
+
existing.modes = { ...dto.modes };
|
|
1075
|
+
existing.banMasks = new Set(dto.banMasks);
|
|
1076
|
+
existing.pendingInvites = new Set(dto.pendingInvites);
|
|
1077
|
+
if (dto.topic !== undefined) existing.topic = dto.topic;
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
887
1081
|
const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
|
|
888
1082
|
for (const entry of dto.members) {
|
|
889
1083
|
members.set(entry.conn, {
|
|
@@ -907,31 +1101,6 @@ class PassthroughChannelAccess {
|
|
|
907
1101
|
}
|
|
908
1102
|
}
|
|
909
1103
|
|
|
910
|
-
/**
|
|
911
|
-
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
912
|
-
*
|
|
913
|
-
* Format: newline-delimited `username:password` pairs (mirroring the
|
|
914
|
-
* `MOTD_LINES` convention). Blank lines and malformed entries (missing
|
|
915
|
-
* colon or empty username/password) are skipped so a trailing newline or a
|
|
916
|
-
* partial edit does not break boot. Exported so the parsing boundary is
|
|
917
|
-
* unit-testable in isolation.
|
|
918
|
-
*/
|
|
919
|
-
export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
|
|
920
|
-
if (raw === undefined || raw.length === 0) return [];
|
|
921
|
-
const out: SaslAccountCredential[] = [];
|
|
922
|
-
for (const line of raw.split('\n')) {
|
|
923
|
-
const trimmed = line.trim();
|
|
924
|
-
if (trimmed.length === 0) continue;
|
|
925
|
-
const sep = trimmed.indexOf(':');
|
|
926
|
-
if (sep <= 0) continue; // missing colon or empty username
|
|
927
|
-
const username = trimmed.slice(0, sep);
|
|
928
|
-
const password = trimmed.slice(sep + 1);
|
|
929
|
-
if (username.length === 0 || password.length === 0) continue;
|
|
930
|
-
out.push({ username, password });
|
|
931
|
-
}
|
|
932
|
-
return out;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
1104
|
/**
|
|
936
1105
|
* Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
|
|
937
1106
|
* encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
|