serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1-backed SASL account store primitives for the Cloudflare adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the AWS `dynamo-account-store.ts` / `account-store.ts` pair:
|
|
5
|
+
* credentials are stored as scrypt hashes (never plaintext) in a D1
|
|
6
|
+
* `accounts` table and pre-loaded at {@link ConnectionDO} construction
|
|
7
|
+
* into the shared {@link HashedAccountStore}.
|
|
8
|
+
*
|
|
9
|
+
* Table-then-config precedence is implemented by {@link resolveAccountStore}:
|
|
10
|
+
* when the D1 table has rows it is authoritative; when it is empty (or
|
|
11
|
+
* unreachable, or the binding is absent) the code falls back to the
|
|
12
|
+
* `SASL_ACCOUNTS` env-var seed — preserving the config-seed behaviour for
|
|
13
|
+
* deployments that have not migrated to D1.
|
|
14
|
+
*
|
|
15
|
+
* The adapters diverge only at the scan/put boundary; the hashing helpers
|
|
16
|
+
* and the synchronous {@link HashedAccountStore} are shared via `irc-core`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type AccountStore,
|
|
21
|
+
type HashedAccountCredential,
|
|
22
|
+
HashedAccountStore,
|
|
23
|
+
InMemoryAccountStore,
|
|
24
|
+
hashAccountCredential,
|
|
25
|
+
} from '@serverless-ircd/irc-core';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SQL to create the `accounts` table. Exported so the seed tool and
|
|
29
|
+
* provisioning docs reference one schema definition.
|
|
30
|
+
*/
|
|
31
|
+
export const CREATE_ACCOUNTS_TABLE_SQL =
|
|
32
|
+
'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A raw D1 row — the untyped shape returned by `prepare().all()`. Each
|
|
36
|
+
* column is `unknown` until validated into a {@link HashedAccountCredential}.
|
|
37
|
+
*/
|
|
38
|
+
type AccountsRow = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Coerces a raw D1 row into a {@link HashedAccountCredential}, or returns
|
|
42
|
+
* `undefined` when the row is missing required columns or has the wrong
|
|
43
|
+
* types. Mirrors the silent-skip behaviour of the DynamoDB loader.
|
|
44
|
+
*/
|
|
45
|
+
function coerceRow(row: AccountsRow): HashedAccountCredential | undefined {
|
|
46
|
+
const { account, algorithm, salt, hash } = row;
|
|
47
|
+
if (
|
|
48
|
+
typeof account !== 'string' ||
|
|
49
|
+
typeof algorithm !== 'string' ||
|
|
50
|
+
typeof salt !== 'string' ||
|
|
51
|
+
typeof hash !== 'string'
|
|
52
|
+
) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
account,
|
|
57
|
+
algorithm: algorithm as HashedAccountCredential['algorithm'],
|
|
58
|
+
salt,
|
|
59
|
+
hash,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Queries the D1 `accounts` table and returns a fully-populated
|
|
65
|
+
* {@link HashedAccountStore}, or `undefined` when the table has no rows.
|
|
66
|
+
*
|
|
67
|
+
* Rows missing required attributes are silently skipped. Callers that need
|
|
68
|
+
* the table-then-config fallback should use {@link resolveAccountStore}
|
|
69
|
+
* rather than catching `undefined` themselves.
|
|
70
|
+
*
|
|
71
|
+
* @param db The D1 binding (may be `undefined` when D1 is not configured).
|
|
72
|
+
*/
|
|
73
|
+
export async function loadD1AccountStore(
|
|
74
|
+
db: D1Database | undefined,
|
|
75
|
+
): Promise<HashedAccountStore | undefined> {
|
|
76
|
+
if (db === undefined) return undefined;
|
|
77
|
+
const result = await db
|
|
78
|
+
.prepare('SELECT account, algorithm, salt, hash FROM accounts')
|
|
79
|
+
.all<AccountsRow>();
|
|
80
|
+
const entries: HashedAccountCredential[] = [];
|
|
81
|
+
for (const row of result.results ?? []) {
|
|
82
|
+
const entry = coerceRow(row);
|
|
83
|
+
if (entry !== undefined) entries.push(entry);
|
|
84
|
+
}
|
|
85
|
+
if (entries.length === 0) return undefined;
|
|
86
|
+
return new HashedAccountStore(entries);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Writes (or re-seeds) a SASL PLAIN account into the D1 `accounts` table.
|
|
91
|
+
*
|
|
92
|
+
* The password is hashed via {@link hashAccountCredential} (scrypt, random
|
|
93
|
+
* salt) and stored as a {@link HashedAccountCredential} row — plaintext is
|
|
94
|
+
* never written. A repeat call for the same `username` overwrites the prior
|
|
95
|
+
* row (the PK is `account`), so this is the canonical CRUD/seed entry point
|
|
96
|
+
* for oper and SASL account provisioning.
|
|
97
|
+
*
|
|
98
|
+
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
99
|
+
*/
|
|
100
|
+
export async function putAccountCredential(
|
|
101
|
+
db: D1Database,
|
|
102
|
+
username: string,
|
|
103
|
+
password: string,
|
|
104
|
+
): Promise<HashedAccountCredential> {
|
|
105
|
+
const entry = hashAccountCredential(username, password);
|
|
106
|
+
await db
|
|
107
|
+
.prepare('INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)')
|
|
108
|
+
.bind(entry.account, entry.algorithm, entry.salt, entry.hash)
|
|
109
|
+
.run();
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Builds a self-contained `INSERT OR REPLACE` SQL statement that writes a
|
|
115
|
+
* scrypt-hashed account row, suitable for `wrangler d1 execute --command`.
|
|
116
|
+
*
|
|
117
|
+
* The password is hashed via {@link hashAccountCredential}; the plaintext
|
|
118
|
+
* never appears in the SQL. The values are single-quoted with embedded
|
|
119
|
+
* single quotes doubled (`'` → `''`) to avoid injection through the
|
|
120
|
+
* username or the base64 output.
|
|
121
|
+
*
|
|
122
|
+
* This is the pure (non-IO) half of {@link putAccountCredential}; the seed
|
|
123
|
+
* CLI (`tools/seed-cf-accounts.ts`) calls this and pipes the result to
|
|
124
|
+
* `wrangler d1 execute`, while the runtime `ConnectionDO` path uses the
|
|
125
|
+
* bound-parameter {@link putAccountCredential}.
|
|
126
|
+
*
|
|
127
|
+
* @returns The hashed credential and the executable SQL string.
|
|
128
|
+
*/
|
|
129
|
+
export function buildAccountPutStatement(
|
|
130
|
+
username: string,
|
|
131
|
+
password: string,
|
|
132
|
+
): { entry: HashedAccountCredential; sql: string } {
|
|
133
|
+
const entry = hashAccountCredential(username, password);
|
|
134
|
+
const sql = `INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES ('${sqlEscape(entry.account)}', '${sqlEscape(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
|
|
135
|
+
return { entry, sql };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
139
|
+
function sqlEscape(value: string): string {
|
|
140
|
+
return value.replace(/'/g, "''");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves the SASL account store for a CF deployment, applying the
|
|
145
|
+
* table-then-config precedence.
|
|
146
|
+
*
|
|
147
|
+
* 1. Queries the D1 `accounts` table (pre-loading every hashed credential
|
|
148
|
+
* into a {@link HashedAccountStore}). When the table has one or more
|
|
149
|
+
* rows it is authoritative — the table wins and the config seed is
|
|
150
|
+
* ignored.
|
|
151
|
+
* 2. When the table is empty (or the binding is absent), falls back to
|
|
152
|
+
* the `SASL_ACCOUNTS` env-var seed (an {@link InMemoryAccountStore})
|
|
153
|
+
* so existing deployments that have not migrated to D1 keep working.
|
|
154
|
+
* 3. Returns `undefined` when neither source has accounts so the actor's
|
|
155
|
+
* `ctx.accounts` stays unset (`AUTHENTICATE PLAIN` → `904`).
|
|
156
|
+
*
|
|
157
|
+
* Query failures are swallowed and treated as an empty table, falling
|
|
158
|
+
* through to the config seed — mirroring `resolveAccountStore` on AWS.
|
|
159
|
+
*
|
|
160
|
+
* @param db The D1 binding (`undefined` when D1 is not configured).
|
|
161
|
+
* @param saslAccountsRaw The raw `SASL_ACCOUNTS` env var (newline-delimited
|
|
162
|
+
* `username:password` pairs), or `undefined` when unset.
|
|
163
|
+
*/
|
|
164
|
+
export async function resolveAccountStore(
|
|
165
|
+
db: D1Database | undefined,
|
|
166
|
+
saslAccountsRaw: string | undefined,
|
|
167
|
+
): Promise<AccountStore | undefined> {
|
|
168
|
+
try {
|
|
169
|
+
const store = await loadD1AccountStore(db);
|
|
170
|
+
if (store !== undefined) return store;
|
|
171
|
+
} catch {
|
|
172
|
+
// Table might not exist yet or be unreachable; fall through to seed.
|
|
173
|
+
}
|
|
174
|
+
const seed = parseSeed(saslAccountsRaw);
|
|
175
|
+
if (seed.length === 0) return undefined;
|
|
176
|
+
return new InMemoryAccountStore(seed);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parses the `SASL_ACCOUNTS` env var into credential entries for the
|
|
181
|
+
* config-seed fallback. Re-exported from the in-memory account-store module
|
|
182
|
+
* so the precedence resolver is self-contained.
|
|
183
|
+
*/
|
|
184
|
+
function parseSeed(raw: string | undefined): Array<{ username: string; password: string }> {
|
|
185
|
+
if (raw === undefined || raw.length === 0) return [];
|
|
186
|
+
const out: Array<{ username: string; password: string }> = [];
|
|
187
|
+
for (const line of raw.split('\n')) {
|
|
188
|
+
const trimmed = line.trim();
|
|
189
|
+
if (trimmed.length === 0) continue;
|
|
190
|
+
const sep = trimmed.indexOf(':');
|
|
191
|
+
if (sep <= 0) continue;
|
|
192
|
+
const username = trimmed.slice(0, sep);
|
|
193
|
+
const password = trimmed.slice(sep + 1);
|
|
194
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
195
|
+
out.push({ username, password });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
|
|
11
|
-
*
|
|
12
|
-
* authorities.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* four DO namespaces below to coordinate with the registry, channel, and
|
|
12
|
+
* channel-registry authorities. The CfRuntime (036) wires them together
|
|
13
|
+
* behind the platform-agnostic {@link IrcRuntime} port; ConnectionDO
|
|
14
|
+
* (033) only needs `CONNECTION_DO` itself plus the three collaborator
|
|
15
|
+
* namespaces.
|
|
15
16
|
*
|
|
16
17
|
* RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
|
|
17
18
|
* implemented by both the production DOs and the test stubs in
|
|
@@ -22,17 +23,20 @@ export interface Env {
|
|
|
22
23
|
CONNECTION_DO: DurableObjectNamespace;
|
|
23
24
|
/**
|
|
24
25
|
* Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
|
|
25
|
-
* lookupNick.
|
|
26
|
+
* lookupNick. Backed by the sharded {@link RegistryDO} (034), which keys
|
|
27
|
+
* instances by `hash(nick) % N` so the single-threaded DO gives the nick
|
|
28
|
+
* uniqueness invariant for free.
|
|
26
29
|
*
|
|
27
30
|
* Note: the production DO class is what's brand-typed; here we keep the
|
|
28
|
-
* namespace unparameterized and cast to the RPC surface at the call
|
|
29
|
-
* site. The full type plumbing lands with 034.
|
|
31
|
+
* namespace unparameterized and cast to the RPC surface at the call site.
|
|
30
32
|
*/
|
|
31
33
|
REGISTRY_DO: DurableObjectNamespace;
|
|
32
34
|
/**
|
|
33
35
|
* Channel authority, keyed by lowercased channel name. RPC: broadcast /
|
|
34
|
-
* applyChannelDelta / getChannelSnapshot / listMembers.
|
|
35
|
-
*
|
|
36
|
+
* applyChannelDelta / getChannelSnapshot / listMembers. Backed by the
|
|
37
|
+
* {@link ChannelDO} (035), which owns the authoritative roster, modes,
|
|
38
|
+
* topic, and ban list and fans out broadcasts to each member's
|
|
39
|
+
* ConnectionDO.
|
|
36
40
|
*/
|
|
37
41
|
CHANNEL_DO: DurableObjectNamespace;
|
|
38
42
|
/**
|
|
@@ -42,9 +46,29 @@ export interface Env {
|
|
|
42
46
|
* unregister / list.
|
|
43
47
|
*/
|
|
44
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
|
+
/**
|
|
50
|
+
* D1 database backing persistent SASL account credentials. The `accounts`
|
|
51
|
+
* table holds scrypt-hashed rows (`{ account PK, algorithm, salt, hash }`),
|
|
52
|
+
* loaded at {@link ConnectionDO} construction by `loadD1AccountStore`.
|
|
53
|
+
*
|
|
54
|
+
* Optional so deployments that have not migrated to D1 keep working: when
|
|
55
|
+
* unset, the precedence resolver falls straight through to the
|
|
56
|
+
* `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
|
|
57
|
+
*/
|
|
58
|
+
ACCOUNTS_DB?: D1Database;
|
|
45
59
|
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
46
60
|
SERVER_NAME: string;
|
|
47
61
|
NETWORK_NAME: string;
|
|
62
|
+
/**
|
|
63
|
+
* Server version surfaced in `002`/`004`/`351`/`371`. Optional; when unset
|
|
64
|
+
* the reducer falls back to the irc-core default version.
|
|
65
|
+
*/
|
|
66
|
+
SERVER_VERSION?: string;
|
|
67
|
+
/**
|
|
68
|
+
* "Created" text for `003 RPL_CREATED`. Optional; when unset the reducer
|
|
69
|
+
* falls back to the default created text.
|
|
70
|
+
*/
|
|
71
|
+
CREATED_AT?: string;
|
|
48
72
|
MOTD_LINES: string;
|
|
49
73
|
/**
|
|
50
74
|
* SASL PLAIN accounts seeded into the in-worker `AccountStore`.
|
|
@@ -2,14 +2,9 @@
|
|
|
2
2
|
* @serverless-ircd/cf-adapter
|
|
3
3
|
*
|
|
4
4
|
* Cloudflare Workers adapter for ServerlessIRCd. Implements the three
|
|
5
|
-
* Durable Objects (ConnectionDO, RegistryDO, ChannelDO)
|
|
6
|
-
* CfRuntime that bridges them behind the
|
|
7
|
-
* {@link IrcRuntime} port.
|
|
8
|
-
*
|
|
9
|
-
* 033 shipped the {@link ConnectionDO} + a stub CfRuntime.
|
|
10
|
-
* 034 ships the {@link RegistryDO} (real nick registry with
|
|
11
|
-
* sharding). The {@link ChannelDO} (roster + modes + fanout) ships
|
|
12
|
-
* here. The full CfRuntime lands in 036.
|
|
5
|
+
* Durable Objects (ConnectionDO, RegistryDO, ChannelDO), the
|
|
6
|
+
* ChannelRegistryDO index, and a CfRuntime that bridges them behind the
|
|
7
|
+
* platform-agnostic {@link IrcRuntime} port.
|
|
13
8
|
*/
|
|
14
9
|
|
|
15
10
|
export {
|
|
@@ -29,6 +24,12 @@ export {
|
|
|
29
24
|
export { makeCfRuntime } from './cf-runtime.js';
|
|
30
25
|
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
31
26
|
export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
27
|
+
export {
|
|
28
|
+
CREATE_ACCOUNTS_TABLE_SQL,
|
|
29
|
+
loadD1AccountStore,
|
|
30
|
+
putAccountCredential,
|
|
31
|
+
resolveAccountStore,
|
|
32
|
+
} from './d1-account-store.js';
|
|
32
33
|
export {
|
|
33
34
|
PERSISTED_STATE_VERSION,
|
|
34
35
|
STATE_STORAGE_KEY,
|
|
@@ -65,7 +65,8 @@ export interface CfHarnessEnv {
|
|
|
65
65
|
*/
|
|
66
66
|
export function makeCfHarnessFactory(opts: CfHarnessFactoryOptions): IrcHarnessFactory {
|
|
67
67
|
return Object.freeze({
|
|
68
|
-
name: 'cf',
|
|
68
|
+
name: 'cf+ws',
|
|
69
|
+
transport: 'ws',
|
|
69
70
|
async create() {
|
|
70
71
|
return new CfHarness(opts);
|
|
71
72
|
},
|
|
@@ -204,6 +205,15 @@ class CfClientHandle implements ClientHarness {
|
|
|
204
205
|
this.ws.send(`${line}\r\n`);
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
async feed(chunk: string): Promise<void> {
|
|
209
|
+
if (this.closed) return;
|
|
210
|
+
// The CF harness speaks the WebSocket transport end-to-end (a real WS
|
|
211
|
+
// to a ConnectionDO), so one feed() is one text frame — identical to
|
|
212
|
+
// send() minus the framing. A CF+tcp factory (Spectrum + container
|
|
213
|
+
// origin) would route this through a TCP byte stream.
|
|
214
|
+
this.ws.send(chunk);
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
async waitForLine(
|
|
208
218
|
predicate: (line: string) => boolean,
|
|
209
219
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
@@ -101,6 +101,28 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
|
101
101
|
});
|
|
102
102
|
expect(cfg.quitMessage).toBe('Bye from CF');
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
it('threads SERVER_VERSION into serverVersion', () => {
|
|
106
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), SERVER_VERSION: '1.2.3' });
|
|
107
|
+
expect(cfg.serverVersion).toBe('1.2.3');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('passes CREATED_AT verbatim when it is non-numeric', () => {
|
|
111
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '2024-06-01' });
|
|
112
|
+
expect(cfg.createdAt).toBe('2024-06-01');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
|
|
116
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '1700000000000' });
|
|
117
|
+
expect(cfg.createdAt).toBe(1_700_000_000_000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('applies the schema defaults for serverVersion/createdAt when unset', () => {
|
|
121
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
122
|
+
expect(typeof cfg.serverVersion).toBe('string');
|
|
123
|
+
expect(cfg.serverVersion.length).toBeGreaterThan(0);
|
|
124
|
+
expect(cfg.createdAt).toBeDefined();
|
|
125
|
+
});
|
|
104
126
|
});
|
|
105
127
|
|
|
106
128
|
describe('loadServerConfigFromCfEnv — failure modes', () => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 085 — CF `webSocketMessage` channel-registration: wire ChannelDO
|
|
3
|
+
* registration OR remove the dead loop.
|
|
4
|
+
*
|
|
5
|
+
* Spike conclusion (documented in the ticket): the dead loop that
|
|
6
|
+
* iterated `state.joinedChannels` to "register" the ConnectionDO with
|
|
7
|
+
* each ChannelDO is redundant. Channel membership — and therefore
|
|
8
|
+
* broadcast fan-out registration — is driven solely by the
|
|
9
|
+
* `ApplyChannelDelta` effect the joinReducer emits. The CfRuntime
|
|
10
|
+
* forwards that effect to the authoritative ChannelDO
|
|
11
|
+
* (`cf-runtime.ts` → `channel-do.ts#applyChannelDelta`), whose roster
|
|
12
|
+
* is the single source of truth used by `ChannelDO.broadcast`. The
|
|
13
|
+
* `connection-do.test.ts` "QUIT fanout to each joined channel" test
|
|
14
|
+
* proves this end-to-end (alice JOINs, closes, and bob observes the
|
|
15
|
+
* QUIT that only the ChannelDO roster could have routed).
|
|
16
|
+
*
|
|
17
|
+
* Because the removed loop was a no-op (`void chan;`), there is no
|
|
18
|
+
* behaviour to assert — these are source-level regression guards that
|
|
19
|
+
* prevent the redundant registration loop from creeping back in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import connectionDoSource from '../src/connection-do.ts?raw';
|
|
24
|
+
|
|
25
|
+
describe('ConnectionDO — no redundant channel-registration loop', () => {
|
|
26
|
+
it('webSocketMessage contains no dead `void chan` no-op registration body', () => {
|
|
27
|
+
// The joinReducer's `ApplyChannelDelta` effect is the sole channel
|
|
28
|
+
// registration path; a per-ConnectionDO loop here is dead code.
|
|
29
|
+
expect(connectionDoSource).not.toContain('void chan');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('documents that no explicit per-ConnectionDO registration step is needed', () => {
|
|
33
|
+
// The absence of the loop must be explained so it is not mistaken
|
|
34
|
+
// for a missing feature.
|
|
35
|
+
expect(connectionDoSource).toContain('No per-ConnectionDO channel-registration step is needed');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 087 — CF outbound batching: implement OR drop the reserved optimization.
|
|
3
|
+
*
|
|
4
|
+
* Spike conclusion (documented in the ticket): the speculative per-microtask
|
|
5
|
+
* outbound drain that was reserved as a commented-out field in
|
|
6
|
+
* `ConnectionDO` is dropped. Per-call batching is already implemented —
|
|
7
|
+
* `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
|
|
8
|
+
* a single `WebSocket.send()`, and the actor `send` handler
|
|
9
|
+
* (`buildActor`) joins every line of one frame's response the same way.
|
|
10
|
+
* What the reservation speculated about was *cross-call* coalescing —
|
|
11
|
+
* queueing lines emitted by independent `deliver` / `send` invocations
|
|
12
|
+
* within the same microtask and flushing them in one write. That adds a
|
|
13
|
+
* drain queue, a `queueMicrotask` scheduler, and ordering hazards between
|
|
14
|
+
* independent fan-out calls, in exchange for no measured benefit: nothing
|
|
15
|
+
* in the load-test report or the ChannelDO fan-out path shows WS
|
|
16
|
+
* round-trips as a bottleneck. The AWS adapter batches at a
|
|
17
|
+
* different layer (per-invocation `outbound: string[]` accumulated then
|
|
18
|
+
* posted once), so there is no shared abstraction worth extracting.
|
|
19
|
+
*
|
|
20
|
+
* Because the removed reservation was a comment with no runtime
|
|
21
|
+
* behaviour, these are source-level regression guards (mirroring the
|
|
22
|
+
* pattern used for other dropped reservations) that prevent the
|
|
23
|
+
* speculative field from creeping back in and keep the deferral
|
|
24
|
+
* documented at the point a reader would otherwise expect the batching
|
|
25
|
+
* hook.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from 'vitest';
|
|
29
|
+
import connectionDoSource from '../src/connection-do.ts?raw';
|
|
30
|
+
|
|
31
|
+
describe('ConnectionDO — no reserved outbound-batching field', () => {
|
|
32
|
+
it('does not contain the deferred "Reserved for a future batching optimization" comment', () => {
|
|
33
|
+
// The reservation is dropped; per-call batching in `deliver()` and
|
|
34
|
+
// the actor `send` handler is the implemented strategy.
|
|
35
|
+
expect(connectionDoSource).not.toContain('Reserved for a future batching optimization');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not reintroduce a speculative outbound-drain field declaration', () => {
|
|
39
|
+
// Guards against any variant of the commented-out reservation. The
|
|
40
|
+
// implemented batching is per-call (join lines then `send` once);
|
|
41
|
+
// there is no instance-level drain queue.
|
|
42
|
+
expect(connectionDoSource).not.toContain('Outstanding outbound lines');
|
|
43
|
+
expect(connectionDoSource).not.toContain('drop the reservation');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('documents the deferral decision at the point the reservation used to live', () => {
|
|
47
|
+
// The absence of the field must be explained so it is not mistaken
|
|
48
|
+
// for a missing feature, and a reader is pointed at the perf ADR.
|
|
49
|
+
expect(connectionDoSource).toContain('batched per call');
|
|
50
|
+
expect(connectionDoSource).toContain('ADR-003');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the full precedence matrix:
|
|
5
|
+
* - D1 has rows → table is authoritative (900/903 for seeded account,
|
|
6
|
+
* 904 for the env-seed account which the table shadows).
|
|
7
|
+
* - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
|
|
8
|
+
*
|
|
9
|
+
* Each test file gets its own isolated D1 database from miniflare, so the
|
|
10
|
+
* table state set up here does not leak into `connection-do.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { env } from 'cloudflare:test';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
namespace Cloudflare {
|
|
19
|
+
interface Env {
|
|
20
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
21
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
22
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
23
|
+
ACCOUNTS_DB: D1Database;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class IrcWsClient {
|
|
29
|
+
readonly received: string[] = [];
|
|
30
|
+
readonly ws: WebSocket;
|
|
31
|
+
|
|
32
|
+
constructor(ws: WebSocket) {
|
|
33
|
+
this.ws = ws;
|
|
34
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
35
|
+
const d = ev.data as string;
|
|
36
|
+
for (const line of d.split('\r\n')) {
|
|
37
|
+
if (line.length > 0) this.received.push(line);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
send(line: string): void {
|
|
43
|
+
this.ws.send(line);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
waitForMessage(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
|
|
47
|
+
return new Promise<string>((resolve, reject) => {
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
const tick = (): void => {
|
|
50
|
+
const hit = this.received.find(predicate);
|
|
51
|
+
if (hit !== undefined) {
|
|
52
|
+
resolve(hit);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() - start > timeoutMs) {
|
|
56
|
+
reject(new Error(`timeout waiting for message; got: ${JSON.stringify(this.received)}`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setTimeout(tick, 20);
|
|
60
|
+
};
|
|
61
|
+
tick();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): Promise<void> {
|
|
66
|
+
return new Promise<void>((resolve) => {
|
|
67
|
+
this.ws.addEventListener('close', () => resolve());
|
|
68
|
+
this.ws.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function connect(connId: string): Promise<IrcWsClient> {
|
|
74
|
+
const doId = env.CONNECTION_DO.idFromName(connId);
|
|
75
|
+
const stub = env.CONNECTION_DO.get(doId);
|
|
76
|
+
const response = await stub.fetch('https://do/upgrade', {
|
|
77
|
+
headers: { Upgrade: 'websocket' },
|
|
78
|
+
});
|
|
79
|
+
const ws = response.webSocket;
|
|
80
|
+
if (ws === undefined || ws === null) {
|
|
81
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
82
|
+
}
|
|
83
|
+
ws.accept();
|
|
84
|
+
return new Promise<IrcWsClient>((resolve) => {
|
|
85
|
+
setTimeout(() => resolve(new IrcWsClient(ws as WebSocket)), 0);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** base64(`\0authcid\0password`) — the PLAIN payload format. */
|
|
90
|
+
function plainB64(authcid: string, password: string): string {
|
|
91
|
+
const raw = `\0${authcid}\0${password}`;
|
|
92
|
+
let latin1 = '';
|
|
93
|
+
for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
|
|
94
|
+
return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Negotiates `CAP sasl` and sends an AUTHENTICATE PLAIN payload. */
|
|
98
|
+
async function authenticatePlain(
|
|
99
|
+
client: IrcWsClient,
|
|
100
|
+
authcid: string,
|
|
101
|
+
password: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
client.send('CAP REQ :sasl\r\n');
|
|
104
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
105
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
106
|
+
await client.waitForMessage((l) => l === 'AUTHENTICATE +');
|
|
107
|
+
client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
|
|
113
|
+
});
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
|
|
119
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
|
|
120
|
+
const client = await connect('conn-d1-sasl-1');
|
|
121
|
+
await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
|
|
122
|
+
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
123
|
+
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
124
|
+
expect(success).toContain(' 903 ');
|
|
125
|
+
await client.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
|
|
129
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
|
|
130
|
+
const client = await connect('conn-d1-sasl-2');
|
|
131
|
+
await authenticatePlain(client, 'd1-alice', 'wrong');
|
|
132
|
+
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
133
|
+
expect(fail).toContain(' 904 ');
|
|
134
|
+
await client.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('table shadows the env-seed account (table wins)', async () => {
|
|
138
|
+
// SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
|
|
139
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
|
|
140
|
+
const client = await connect('conn-d1-sasl-3');
|
|
141
|
+
// env-seed account does NOT verify (table is authoritative).
|
|
142
|
+
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
143
|
+
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
144
|
+
expect(fail).toContain(' 904 ');
|
|
145
|
+
await client.close();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
|
|
150
|
+
// The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
|
|
153
|
+
});
|
|
154
|
+
afterEach(async () => {
|
|
155
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
|
|
159
|
+
const client = await connect('conn-d1-sasl-fallback');
|
|
160
|
+
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
161
|
+
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
162
|
+
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
163
|
+
expect(success).toContain(' 903 ');
|
|
164
|
+
await client.close();
|
|
165
|
+
});
|
|
166
|
+
});
|