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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1-backed SASL account store + precedence resolver.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real D1 round-trip via miniflare: `putAccountCredential`
|
|
5
|
+
* writes a hashed row, `loadD1AccountStore` reads it back, and
|
|
6
|
+
* `resolveAccountStore` applies the table-then-config precedence.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { env } from 'cloudflare:test';
|
|
10
|
+
import { hashAccountCredential } from '@serverless-ircd/irc-core';
|
|
11
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
buildAccountPutStatement,
|
|
14
|
+
loadD1AccountStore,
|
|
15
|
+
putAccountCredential,
|
|
16
|
+
resolveAccountStore,
|
|
17
|
+
} from '../src/d1-account-store';
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
namespace Cloudflare {
|
|
21
|
+
interface Env {
|
|
22
|
+
ACCOUNTS_DB: D1Database;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CREATE_TABLE =
|
|
28
|
+
'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
29
|
+
|
|
30
|
+
async function resetDb(): Promise<void> {
|
|
31
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
32
|
+
await env.ACCOUNTS_DB.exec(CREATE_TABLE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('loadD1AccountStore', () => {
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await resetDb();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns undefined when the table has no rows', async () => {
|
|
41
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
42
|
+
expect(store).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('verifies an account after a row is written via putAccountCredential', async () => {
|
|
46
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 's3cret');
|
|
47
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
48
|
+
expect(store).toBeDefined();
|
|
49
|
+
expect(
|
|
50
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
|
|
51
|
+
).toEqual({ ok: true, account: 'alice' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a wrong password after round-trip', async () => {
|
|
55
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'right');
|
|
56
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
57
|
+
expect(store).toBeDefined();
|
|
58
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('loads multiple distinct accounts', async () => {
|
|
64
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'a1');
|
|
65
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'bob', 'b2');
|
|
66
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
67
|
+
expect(store?.size).toBe(2);
|
|
68
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
|
|
69
|
+
true,
|
|
70
|
+
);
|
|
71
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('overwrites an existing account on re-seed', async () => {
|
|
77
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'old');
|
|
78
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'new');
|
|
79
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
80
|
+
expect(store?.size).toBe(1);
|
|
81
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('skips rows missing required columns', async () => {
|
|
90
|
+
// Use a permissive table (no NOT NULL) to simulate a row with a missing
|
|
91
|
+
// salt — the production schema's NOT NULL prevents this, but the loader
|
|
92
|
+
// stays defensive against schema drift / manual edits.
|
|
93
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE accounts');
|
|
94
|
+
await env.ACCOUNTS_DB.exec(
|
|
95
|
+
'CREATE TABLE accounts (account TEXT PRIMARY KEY, algorithm TEXT, salt TEXT, hash TEXT)',
|
|
96
|
+
);
|
|
97
|
+
await env.ACCOUNTS_DB.prepare(
|
|
98
|
+
"INSERT INTO accounts (account, algorithm, hash) VALUES ('bad', 'scrypt', 'bbbb')",
|
|
99
|
+
).run();
|
|
100
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
101
|
+
expect(store).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('putAccountCredential', () => {
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
await resetDb();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('writes a hashed row that round-trips through the store', async () => {
|
|
111
|
+
const entry = await putAccountCredential(env.ACCOUNTS_DB, 'carol', 'p@ss');
|
|
112
|
+
expect(entry.account).toBe('carol');
|
|
113
|
+
expect(entry.algorithm).toBe('scrypt');
|
|
114
|
+
|
|
115
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
116
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
|
|
117
|
+
ok: true,
|
|
118
|
+
account: 'carol',
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('never writes the plaintext password to the row', async () => {
|
|
123
|
+
const entry = await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'hunter2-secret');
|
|
124
|
+
expect(entry.account).not.toContain('hunter2');
|
|
125
|
+
expect(entry.salt).not.toContain('hunter2');
|
|
126
|
+
expect(entry.hash).not.toContain('hunter2');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('matches a manually-hashed entry written via raw D1 insert', async () => {
|
|
130
|
+
const hashed = hashAccountCredential('dave', 'pw');
|
|
131
|
+
await env.ACCOUNTS_DB.prepare(
|
|
132
|
+
'INSERT INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)',
|
|
133
|
+
)
|
|
134
|
+
.bind(hashed.account, hashed.algorithm, hashed.salt, hashed.hash)
|
|
135
|
+
.run();
|
|
136
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
137
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'dave', password: 'pw' }).ok).toBe(
|
|
138
|
+
true,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('buildAccountPutStatement (seed SQL builder)', () => {
|
|
144
|
+
it('produces an INSERT OR REPLACE statement with scrypt-hashed values', () => {
|
|
145
|
+
const { entry, sql } = buildAccountPutStatement('alice', 's3cret');
|
|
146
|
+
expect(entry.account).toBe('alice');
|
|
147
|
+
expect(entry.algorithm).toBe('scrypt');
|
|
148
|
+
expect(sql).toContain('INSERT OR REPLACE INTO accounts');
|
|
149
|
+
// Plaintext must never appear in the SQL.
|
|
150
|
+
expect(sql).not.toContain('s3cret');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('the generated SQL round-trips through loadD1AccountStore', async () => {
|
|
154
|
+
await resetDb();
|
|
155
|
+
const { sql } = buildAccountPutStatement('carol', 'p@ss');
|
|
156
|
+
await env.ACCOUNTS_DB.exec(sql);
|
|
157
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
158
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
|
|
159
|
+
ok: true,
|
|
160
|
+
account: 'carol',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('escapes single quotes in the username to avoid breaking the SQL', async () => {
|
|
165
|
+
await resetDb();
|
|
166
|
+
const { sql } = buildAccountPutStatement("o'brien", 'pw');
|
|
167
|
+
await env.ACCOUNTS_DB.exec(sql);
|
|
168
|
+
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
169
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: "o'brien", password: 'pw' }).ok).toBe(
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('escapes single quotes in the base64 hash output', () => {
|
|
175
|
+
// The hash is base64 so it won't contain quotes, but the escaper is
|
|
176
|
+
// still exercised — verify the SQL is well-formed.
|
|
177
|
+
const { sql } = buildAccountPutStatement('alice', 'secret');
|
|
178
|
+
// A valid INSERT with four quoted values.
|
|
179
|
+
expect(sql.match(/'/g)?.length).toBe(8);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('resolveAccountStore precedence', () => {
|
|
184
|
+
beforeEach(async () => {
|
|
185
|
+
await resetDb();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns the D1 store when the table has rows (table wins)', async () => {
|
|
189
|
+
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'table-secret');
|
|
190
|
+
const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
|
|
191
|
+
expect(store).toBeDefined();
|
|
192
|
+
expect(
|
|
193
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
|
|
194
|
+
).toBe(true);
|
|
195
|
+
// Config account does NOT verify (table is authoritative).
|
|
196
|
+
expect(
|
|
197
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
198
|
+
).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('falls back to the SASL_ACCOUNTS seed when the table is empty', async () => {
|
|
202
|
+
const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
|
|
203
|
+
expect(store).toBeDefined();
|
|
204
|
+
expect(
|
|
205
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
206
|
+
).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns undefined when the table is empty and no seed is set', async () => {
|
|
210
|
+
const store = await resolveAccountStore(env.ACCOUNTS_DB, undefined);
|
|
211
|
+
expect(store).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('returns undefined when the table is empty and the seed is blank', async () => {
|
|
215
|
+
const store = await resolveAccountStore(env.ACCOUNTS_DB, '');
|
|
216
|
+
expect(store).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('falls back to the seed when the D1 binding is undefined', async () => {
|
|
220
|
+
const store = await resolveAccountStore(undefined, 'envuser:env-secret');
|
|
221
|
+
expect(store).toBeDefined();
|
|
222
|
+
expect(
|
|
223
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
224
|
+
).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declaration so `?raw` imports (Vite's built-in transform that
|
|
3
|
+
* inlines a file's text at bundle time) typecheck under the strict
|
|
4
|
+
* `tsconfig.test.json`. Used by source-level regression guards that
|
|
5
|
+
* assert dead code stays removed.
|
|
6
|
+
*/
|
|
7
|
+
declare module '*?raw' {
|
|
8
|
+
const content: string;
|
|
9
|
+
// eslint-disable-next-line import/no-default-export
|
|
10
|
+
export default content;
|
|
11
|
+
}
|
|
@@ -25,7 +25,14 @@ import { RegistryDO } from '../../src/registry-do';
|
|
|
25
25
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
26
26
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
27
27
|
|
|
28
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
ChannelDO,
|
|
30
|
+
ChannelRegistryDO,
|
|
31
|
+
ConnectionDO,
|
|
32
|
+
RegistryDO,
|
|
33
|
+
RecordingRegistryDO,
|
|
34
|
+
RecordingChannelDO,
|
|
35
|
+
};
|
|
29
36
|
|
|
30
37
|
export default {
|
|
31
38
|
async fetch(): Promise<Response> {
|
|
@@ -14,6 +14,14 @@ MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scena
|
|
|
14
14
|
# an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
|
|
15
15
|
SASL_ACCOUNTS = "cf-sasl:s3cret"
|
|
16
16
|
|
|
17
|
+
# D1 database backing persistent SASL accounts. The D1 account-store suite
|
|
18
|
+
# creates the `accounts` table in a beforeEach and exercises the real
|
|
19
|
+
# load/put/resolve path.
|
|
20
|
+
[[d1_databases]]
|
|
21
|
+
binding = "ACCOUNTS_DB"
|
|
22
|
+
database_name = "cf-adapter-test-accounts"
|
|
23
|
+
database_id = "test-d1-accounts"
|
|
24
|
+
|
|
17
25
|
# Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
|
|
18
26
|
# classes so CfRuntime, ConnectionDO, and the integration suite all
|
|
19
27
|
# exercise the actual sharded-registry + per-channel fanout path.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/irc-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
+
"prepare": "node scripts/generate-build-info.mjs",
|
|
22
|
+
"prebuild": "node scripts/generate-build-info.mjs",
|
|
23
|
+
"pretypecheck": "node scripts/generate-build-info.mjs",
|
|
24
|
+
"pretest": "node scripts/generate-build-info.mjs",
|
|
21
25
|
"build": "tsc -p tsconfig.build.json",
|
|
22
26
|
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
23
27
|
"test": "vitest run",
|
|
@@ -35,6 +39,7 @@
|
|
|
35
39
|
"devDependencies": {
|
|
36
40
|
"@stryker-mutator/core": "^9.6.1",
|
|
37
41
|
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
42
|
+
"@types/node": "^26.1.1",
|
|
38
43
|
"vitest": "^4.1.0"
|
|
39
44
|
}
|
|
40
45
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates `src/build-info.ts` containing the build timestamp.
|
|
3
|
+
*
|
|
4
|
+
* Runs as a `prebuild` / `pretest` / `pretypecheck` / `prepare` hook so the
|
|
5
|
+
* file always exists before tsc or vitest resolves `./build-info.js` imports.
|
|
6
|
+
* The emitted file is gitignored — it is regenerated on every build so the
|
|
7
|
+
* published `dist/index.js` carries the timestamp of the build that produced
|
|
8
|
+
* it, never a stale committed value.
|
|
9
|
+
*
|
|
10
|
+
* Output shape:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.
|
|
14
|
+
* export const BUILD_DATE = '2024-01-01T00:00:00.000Z';
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Failures here must abort the build rather than emit a half-file: a missing
|
|
18
|
+
* or malformed `build-info.ts` would break every downstream tsc invocation.
|
|
19
|
+
*/
|
|
20
|
+
import { writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const target = resolve(here, '..', 'src', 'build-info.ts');
|
|
26
|
+
|
|
27
|
+
const iso = new Date().toISOString();
|
|
28
|
+
const contents = `// AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.\nexport const BUILD_DATE = ${JSON.stringify(iso)};\n`;
|
|
29
|
+
|
|
30
|
+
writeFileSync(target, contents, 'utf8');
|
|
31
|
+
console.log(`[generate-build-info] wrote ${target} (BUILD_DATE=${iso})`);
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
export interface Capability {
|
|
14
14
|
/** Wire name (`server-time`, `sasl`, …). */
|
|
15
15
|
name: string;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Optional value parameter. For `sasl` this is the base mechanism list
|
|
18
|
+
* (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
|
|
19
|
+
* `EXTERNAL` when mTLS is configured for a connection.
|
|
20
|
+
*/
|
|
17
21
|
value?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
@@ -33,10 +37,27 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
|
33
37
|
{ name: 'message-tags' },
|
|
34
38
|
{ name: 'multi-prefix' },
|
|
35
39
|
{ name: 'safelist' },
|
|
36
|
-
{ name: 'sasl', value: 'PLAIN
|
|
40
|
+
{ name: 'sasl', value: 'PLAIN' },
|
|
37
41
|
{ name: 'server-time' },
|
|
38
42
|
]);
|
|
39
43
|
|
|
44
|
+
/** Wire name of the SASL capability. */
|
|
45
|
+
export const SASL_CAP_NAME = 'sasl';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the `sasl` cap value to advertise given whether an mTLS identity
|
|
49
|
+
* provider is bound for the connection.
|
|
50
|
+
*
|
|
51
|
+
* PLAIN is always available. EXTERNAL is advertised only when mTLS is
|
|
52
|
+
* configured (an `MtlsIdentityProvider` is bound) so clients on
|
|
53
|
+
* non-mTLS connections do not attempt a mechanism that cannot succeed.
|
|
54
|
+
* Operators enable EXTERNAL by configuring mTLS at the edge (CF API Shield
|
|
55
|
+
* mTLS, AWS custom-domain mTLS) — no code change required.
|
|
56
|
+
*/
|
|
57
|
+
export function saslCapValue(hasMtls: boolean): string {
|
|
58
|
+
return hasMtls ? 'PLAIN,EXTERNAL' : 'PLAIN';
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
/** Set form for fast membership checks during `CAP REQ`. */
|
|
41
62
|
export const SUPPORTED_CAP_NAMES: ReadonlySet<string> = new Set<string>(
|
|
42
63
|
SUPPORTED_CAPABILITIES.map((c) => c.name),
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Implementation note: this uses a self-contained FNV-1a 32-bit hash plus a
|
|
13
13
|
* short base32 encoding so the module stays pure-TS and runs unchanged on
|
|
14
|
-
* Node
|
|
14
|
+
* Node 24, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
|
|
15
15
|
* an attacker who learns one cloak cannot recover the secret, but a
|
|
16
16
|
* determined attacker with the secret can forge cloaks. Deployments that
|
|
17
17
|
* need stronger unforgeability should override this module.
|
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
|
+
SASL_CAP_NAME,
|
|
36
37
|
SUPPORTED_CAPABILITIES,
|
|
37
38
|
capToLsString,
|
|
38
39
|
isSupportedCap,
|
|
40
|
+
saslCapValue,
|
|
39
41
|
splitCapsForWire,
|
|
40
42
|
} from '../caps/capabilities.js';
|
|
41
43
|
import { Effect } from '../effects.js';
|
|
@@ -88,7 +90,12 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
|
|
|
88
90
|
state.capNegotiating = true;
|
|
89
91
|
|
|
90
92
|
const target = state.nick ?? '*';
|
|
91
|
-
const
|
|
93
|
+
const hasMtls = ctx.mtlsIdentity !== undefined;
|
|
94
|
+
const tokens = SUPPORTED_CAPABILITIES.map((cap) =>
|
|
95
|
+
cap.name === SASL_CAP_NAME
|
|
96
|
+
? capToLsString({ ...cap, value: saslCapValue(hasMtls) })
|
|
97
|
+
: capToLsString(cap),
|
|
98
|
+
);
|
|
92
99
|
const maxLen = maxCapsContentLen(ctx, target);
|
|
93
100
|
const chunks = splitCapsForWire(tokens, maxLen);
|
|
94
101
|
|
|
@@ -34,6 +34,7 @@ export { channelModeReducer, userModeReducer } from './mode.js';
|
|
|
34
34
|
export { inviteReducer } from './invite.js';
|
|
35
35
|
export { whoReducer } from './who.js';
|
|
36
36
|
export { whoisReducer } from './whois.js';
|
|
37
|
+
export { whowasReducer } from './whowas.js';
|
|
37
38
|
export { generateIsupport } from './isupport.js';
|
|
38
39
|
export { capReducer } from './cap.js';
|
|
39
40
|
export { emitChghost } from './chghost.js';
|
|
@@ -47,3 +48,11 @@ export {
|
|
|
47
48
|
CHATHISTORY_CAP,
|
|
48
49
|
} from './chathistory.js';
|
|
49
50
|
export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
51
|
+
export {
|
|
52
|
+
versionReducer,
|
|
53
|
+
timeReducer,
|
|
54
|
+
adminReducer,
|
|
55
|
+
infoReducer,
|
|
56
|
+
} from './server-info.js';
|
|
57
|
+
export { userhostReducer, formatUserhostReply } from './userhost.js';
|
|
58
|
+
export { isonReducer } from './ison.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `ISON` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: ISON is read-only — it checks which of the
|
|
5
|
+
* requested nicks are currently online and emits a single `303 RPL_ISON`
|
|
6
|
+
* line. The reducer itself does not perform lookups (those are async and
|
|
7
|
+
* belong to the actor / runtime layer); it receives the set of online nicks
|
|
8
|
+
* and the requested list, then reports the intersection.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 2812 §5.8):
|
|
11
|
+
* - `ISON <nick>{ <nick>}`
|
|
12
|
+
* - `303 RPL_ISON`:
|
|
13
|
+
* `:<server> 303 <nick> :<online_nick1> <online_nick2> …`
|
|
14
|
+
*
|
|
15
|
+
* Matching is case-insensitive per rfc1459 case-mapping. The reply carries
|
|
16
|
+
* the registered (display) spelling from the online map, not the requester's
|
|
17
|
+
* spelling, so a request for `BOB` reports `Bob` when that is the registered
|
|
18
|
+
* form.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Effect } from '../effects.js';
|
|
22
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
23
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
24
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
25
|
+
import type { Ctx, ReducerResult } from '../types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Handles `ISON <nick>{ <nick>}`.
|
|
29
|
+
*
|
|
30
|
+
* `online` maps each online nick's case-folded (lowercased) form to its
|
|
31
|
+
* display spelling. Each requested nick is folded and looked up; matches are
|
|
32
|
+
* reported using the display spelling from the map.
|
|
33
|
+
*
|
|
34
|
+
* Signature is bespoke (not {@link Reducer}) because the actor must resolve
|
|
35
|
+
* the nicks asynchronously before formatting — mirrors `whoReducer` /
|
|
36
|
+
* `whoisReducer`.
|
|
37
|
+
*/
|
|
38
|
+
export function isonReducer(
|
|
39
|
+
online: ReadonlyMap<string, string>,
|
|
40
|
+
msg: { params: readonly string[] },
|
|
41
|
+
ctx: Ctx,
|
|
42
|
+
): ReducerResult<ConnectionState> {
|
|
43
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
44
|
+
|
|
45
|
+
const found: string[] = [];
|
|
46
|
+
for (const requested of msg.params) {
|
|
47
|
+
if (requested.length === 0) continue;
|
|
48
|
+
const display = online.get(requested.toLowerCase());
|
|
49
|
+
if (display !== undefined) {
|
|
50
|
+
found.push(display);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nick = ctx.connection.nick ?? '*';
|
|
55
|
+
const codeStr = Numerics.RPL_ISON.toString().padStart(3, '0');
|
|
56
|
+
const trailing = found.join(' ');
|
|
57
|
+
const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
|
|
58
|
+
|
|
59
|
+
const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
|
|
60
|
+
return { state: ctx.connection, effects };
|
|
61
|
+
}
|
|
@@ -52,6 +52,18 @@ export const quitReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
effects.push(Effect.releaseNick(nick));
|
|
55
|
+
// Record the sign-off into nick history so WHOWAS can replay it. Only
|
|
56
|
+
// consulted when a NickHistoryStore is bound; absent → no-op.
|
|
57
|
+
if (ctx.history !== undefined) {
|
|
58
|
+
ctx.history.record({
|
|
59
|
+
nick,
|
|
60
|
+
connId: ctx.connId,
|
|
61
|
+
signoffTime: ctx.clock.now(),
|
|
62
|
+
...(state.user !== undefined ? { username: state.user } : {}),
|
|
63
|
+
...(state.host !== undefined ? { hostname: state.host } : {}),
|
|
64
|
+
...(state.realname !== undefined ? { realname: state.realname } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
effects.push(Effect.disconnect(ctx.connId));
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { cloakHost } from '../cloak.js';
|
|
17
|
+
import { formatCreatedAt, resolveServerVersion } from '../config.js';
|
|
17
18
|
import { Effect } from '../effects.js';
|
|
18
19
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
19
20
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -23,15 +24,6 @@ import type { Ctx, Reducer } from '../types.js';
|
|
|
23
24
|
import { generateIsupport } from './isupport.js';
|
|
24
25
|
import { buildMotdNumerics } from './motd-lines.js';
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* Server version reported in `002` / `004`. Placeholder until a config-driven
|
|
28
|
-
* version lands.
|
|
29
|
-
*/
|
|
30
|
-
const SERVER_VERSION = '0.2.0';
|
|
31
|
-
|
|
32
|
-
/** "Created" text for `003 RPL_CREATED`. Placeholder. */
|
|
33
|
-
const CREATED_TEXT = 'Phase 1';
|
|
34
|
-
|
|
35
27
|
/** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
|
|
36
28
|
const USER_MODES_004 = 'iosw';
|
|
37
29
|
|
|
@@ -70,15 +62,17 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
|
|
|
70
62
|
// always defined, so hostmaskOf always returns at least `nick`. The cast
|
|
71
63
|
// avoids a dead `??` branch that v8 coverage cannot satisfy.
|
|
72
64
|
const hostmask = hostmaskOf(state) as string;
|
|
65
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
66
|
+
const created = formatCreatedAt(ctx.serverConfig.createdAt);
|
|
73
67
|
|
|
74
68
|
return [
|
|
75
69
|
{ text: `${prefix} 001 ${nick} :Welcome to the ${ctx.networkName} IRC Network, ${hostmask}` },
|
|
76
70
|
{
|
|
77
|
-
text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${
|
|
71
|
+
text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${version}`,
|
|
78
72
|
},
|
|
79
|
-
{ text: `${prefix} 003 ${nick} :This server was created in ${
|
|
73
|
+
{ text: `${prefix} 003 ${nick} :This server was created in ${created}` },
|
|
80
74
|
{
|
|
81
|
-
text: `${prefix} 004 ${nick} ${ctx.serverName} ${
|
|
75
|
+
text: `${prefix} 004 ${nick} ${ctx.serverName} ${version} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
|
|
82
76
|
},
|
|
83
77
|
// 005 RPL_ISUPPORT — real tokens generated from serverConfig.
|
|
84
78
|
...generateIsupport(ctx.serverConfig, state.caps, nick),
|
|
@@ -214,6 +208,18 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
214
208
|
}
|
|
215
209
|
// hostmaskOf is guaranteed non-undefined here because oldNick is defined.
|
|
216
210
|
const source = hostmaskOf(state) as string;
|
|
211
|
+
// Record the old nick into history so WHOWAS can replay it. Only
|
|
212
|
+
// consulted when a NickHistoryStore is bound; absent → no-op.
|
|
213
|
+
if (ctx.history !== undefined) {
|
|
214
|
+
ctx.history.record({
|
|
215
|
+
nick: oldNick,
|
|
216
|
+
connId: ctx.connId,
|
|
217
|
+
signoffTime: ctx.clock.now(),
|
|
218
|
+
...(state.user !== undefined ? { username: state.user } : {}),
|
|
219
|
+
...(state.host !== undefined ? { hostname: state.host } : {}),
|
|
220
|
+
...(state.realname !== undefined ? { realname: state.realname } : {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
217
223
|
state.nick = nick;
|
|
218
224
|
effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
|
|
219
225
|
effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
|