serverless-ircd 0.2.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 +258 -18
- package/README.md +72 -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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- 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 +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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 +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- 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 +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- 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/cf-runtime.test.ts +91 -0
- 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/connection-do.test.ts +40 -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 +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- 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/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -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 +84 -3
- 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 +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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 +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -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 +182 -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 +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -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 +78 -13
- 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 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -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
|
+
}
|
|
@@ -19,12 +19,20 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
22
|
+
import { ChannelRegistryDO } from '../../src/channel-registry-do';
|
|
22
23
|
import { ConnectionDO } from '../../src/connection-do';
|
|
23
24
|
import { RegistryDO } from '../../src/registry-do';
|
|
24
25
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
25
26
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
26
27
|
|
|
27
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
ChannelDO,
|
|
30
|
+
ChannelRegistryDO,
|
|
31
|
+
ConnectionDO,
|
|
32
|
+
RegistryDO,
|
|
33
|
+
RecordingRegistryDO,
|
|
34
|
+
RecordingChannelDO,
|
|
35
|
+
};
|
|
28
36
|
|
|
29
37
|
export default {
|
|
30
38
|
async fetch(): Promise<Response> {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { DurableObject } from 'cloudflare:workers';
|
|
13
13
|
|
|
14
14
|
export interface RecordedChannelCall {
|
|
15
|
-
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot';
|
|
15
|
+
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot' | 'listMembers';
|
|
16
16
|
args: unknown[];
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -20,6 +20,7 @@ interface ChannelRpc {
|
|
|
20
20
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
21
21
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
22
22
|
getChannelSnapshot(): Promise<unknown>;
|
|
23
|
+
listMembers(): Promise<string[]>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const LOG_KEY = 'channel-call-log';
|
|
@@ -47,4 +48,9 @@ export class RecordingChannelDO extends DurableObject implements ChannelRpc {
|
|
|
47
48
|
await this.append({ method: 'getChannelSnapshot', args: [] });
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
async listMembers(): Promise<string[]> {
|
|
53
|
+
await this.append({ method: 'listMembers', args: [] });
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
50
56
|
}
|
|
@@ -9,6 +9,18 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
9
9
|
# body text in every runtime factory.
|
|
10
10
|
[vars]
|
|
11
11
|
MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
|
|
12
|
+
# SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
|
|
13
|
+
# delimited `username:password` pairs; the connection-do parses these into
|
|
14
|
+
# an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
|
|
15
|
+
SASL_ACCOUNTS = "cf-sasl:s3cret"
|
|
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"
|
|
12
24
|
|
|
13
25
|
# Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
|
|
14
26
|
# classes so CfRuntime, ConnectionDO, and the integration suite all
|
|
@@ -25,6 +37,11 @@ class_name = "RegistryDO"
|
|
|
25
37
|
name = "CHANNEL_DO"
|
|
26
38
|
class_name = "ChannelDO"
|
|
27
39
|
|
|
40
|
+
# Channel registry — single DO tracking all channel names for LIST.
|
|
41
|
+
[[durable_objects.bindings]]
|
|
42
|
+
name = "CHANNEL_REGISTRY_DO"
|
|
43
|
+
class_name = "ChannelRegistryDO"
|
|
44
|
+
|
|
28
45
|
# Recording stubs (kept under separate names for any test that wants to
|
|
29
46
|
# assert "this exact RPC was emitted" rather than the end-to-end effect).
|
|
30
47
|
[[durable_objects.bindings]]
|
|
@@ -47,4 +64,4 @@ class_name = "ChannelDO"
|
|
|
47
64
|
|
|
48
65
|
[[migrations]]
|
|
49
66
|
tag = "v1"
|
|
50
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
67
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type Nick,
|
|
28
28
|
type RawLine,
|
|
29
29
|
applyChannelDelta as applyDelta,
|
|
30
|
+
caseFold,
|
|
30
31
|
createChannel,
|
|
31
32
|
decideAdmission,
|
|
32
33
|
toChannelSnapshot,
|
|
@@ -53,21 +54,6 @@ interface TrackedConnection {
|
|
|
53
54
|
admissionRecordId?: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function lower(s: string): string {
|
|
57
|
-
return s.toLowerCase();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let disabledRecordCounter = 0;
|
|
61
|
-
/**
|
|
62
|
-
* Returns a unique record id for the "admission disabled" path. The id is
|
|
63
|
-
* never consulted (no {@link AdmissionStats} exists), but emitting one
|
|
64
|
-
* keeps {@link admitConnection}'s return shape uniform.
|
|
65
|
-
*/
|
|
66
|
-
function _disabledRecordId(): string {
|
|
67
|
-
disabledRecordCounter += 1;
|
|
68
|
-
return `disabled-${disabledRecordCounter}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
57
|
export interface InMemoryRuntimeOptions {
|
|
72
58
|
clock: Clock;
|
|
73
59
|
/**
|
|
@@ -113,7 +99,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
113
99
|
*/
|
|
114
100
|
admitConnection(ip: string, user: string | undefined): AdmissionDecision {
|
|
115
101
|
if (this.admissionConfig === undefined || this.admissionStats === undefined) {
|
|
116
|
-
return { ok: true, recordId:
|
|
102
|
+
return { ok: true, recordId: '' };
|
|
117
103
|
}
|
|
118
104
|
return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
|
|
119
105
|
}
|
|
@@ -185,7 +171,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
185
171
|
* return the same object.
|
|
186
172
|
*/
|
|
187
173
|
getOrCreateChannel(name: ChanName): ChannelState {
|
|
188
|
-
const key =
|
|
174
|
+
const key = caseFold('rfc1459', name);
|
|
189
175
|
const existing = this.channels.get(key);
|
|
190
176
|
if (existing !== undefined) return existing;
|
|
191
177
|
const created = createChannel({
|
|
@@ -204,7 +190,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
204
190
|
* missing channel from an empty one.
|
|
205
191
|
*/
|
|
206
192
|
getChannel(name: ChanName): ChannelState | undefined {
|
|
207
|
-
return this.channels.get(
|
|
193
|
+
return this.channels.get(caseFold('rfc1459', name));
|
|
208
194
|
}
|
|
209
195
|
|
|
210
196
|
// ------------------------------------------------------------------
|
|
@@ -218,7 +204,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
218
204
|
}
|
|
219
205
|
|
|
220
206
|
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
221
|
-
const channel = this.channels.get(
|
|
207
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
222
208
|
if (channel === undefined) return;
|
|
223
209
|
for (const member of channel.members.keys()) {
|
|
224
210
|
if (member === except) continue;
|
|
@@ -240,7 +226,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
240
226
|
lines: RawLine[],
|
|
241
227
|
notFoundLines?: RawLine[],
|
|
242
228
|
): Promise<void> {
|
|
243
|
-
const owner = this.nicks.get(
|
|
229
|
+
const owner = this.nicks.get(caseFold('rfc1459', nick));
|
|
244
230
|
if (owner !== undefined) {
|
|
245
231
|
const conn = this.connections.get(owner);
|
|
246
232
|
if (conn !== undefined) {
|
|
@@ -261,7 +247,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
261
247
|
// ------------------------------------------------------------------
|
|
262
248
|
|
|
263
249
|
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
264
|
-
const key =
|
|
250
|
+
const key = caseFold('rfc1459', nick);
|
|
265
251
|
const existing = this.nicks.get(key);
|
|
266
252
|
if (existing !== undefined && existing !== conn) {
|
|
267
253
|
return { ok: false };
|
|
@@ -271,8 +257,8 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
271
257
|
}
|
|
272
258
|
|
|
273
259
|
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
274
|
-
const newKey =
|
|
275
|
-
const oldKey =
|
|
260
|
+
const newKey = caseFold('rfc1459', newNick);
|
|
261
|
+
const oldKey = caseFold('rfc1459', oldNick);
|
|
276
262
|
const owner = this.nicks.get(newKey);
|
|
277
263
|
if (owner !== undefined && owner !== conn) {
|
|
278
264
|
return false;
|
|
@@ -287,7 +273,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
287
273
|
}
|
|
288
274
|
|
|
289
275
|
async releaseNick(nick: Nick): Promise<void> {
|
|
290
|
-
this.nicks.delete(
|
|
276
|
+
this.nicks.delete(caseFold('rfc1459', nick));
|
|
291
277
|
}
|
|
292
278
|
|
|
293
279
|
// ------------------------------------------------------------------
|
|
@@ -300,7 +286,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
300
286
|
// replace the stored object so subsequent snapshots see the new data
|
|
301
287
|
// while any outstanding references to the prior state stay coherent.
|
|
302
288
|
const next = applyDelta(channel, delta);
|
|
303
|
-
this.channels.set(
|
|
289
|
+
this.channels.set(caseFold('rfc1459', chan), next);
|
|
304
290
|
}
|
|
305
291
|
|
|
306
292
|
// ------------------------------------------------------------------
|
|
@@ -308,7 +294,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
308
294
|
// ------------------------------------------------------------------
|
|
309
295
|
|
|
310
296
|
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
311
|
-
return this.nicks.get(
|
|
297
|
+
return this.nicks.get(caseFold('rfc1459', nick)) ?? null;
|
|
312
298
|
}
|
|
313
299
|
|
|
314
300
|
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
@@ -328,13 +314,13 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
328
314
|
}
|
|
329
315
|
|
|
330
316
|
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
331
|
-
const channel = this.channels.get(
|
|
317
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
332
318
|
if (channel === undefined) return null;
|
|
333
319
|
return toChannelSnapshot(channel);
|
|
334
320
|
}
|
|
335
321
|
|
|
336
322
|
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
337
|
-
const channel = this.channels.get(
|
|
323
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
338
324
|
const out = new Map<ConnId, ConnectionState>();
|
|
339
325
|
if (channel === undefined) return out;
|
|
340
326
|
for (const connId of channel.members.keys()) {
|
|
@@ -227,6 +227,14 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
|
|
|
227
227
|
expect(snap?.members.has('c1')).toBe(true);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
it('treats rfc1459 special chars [ \\ ] ^ as equal to { | } ~', async () => {
|
|
231
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
232
|
+
const a = rt.getOrCreateChannel('#[Foo]');
|
|
233
|
+
const b = rt.getOrCreateChannel('#{foo}');
|
|
234
|
+
expect(b).toBe(a);
|
|
235
|
+
expect(b.nameLower).toBe('#{foo}');
|
|
236
|
+
});
|
|
237
|
+
|
|
230
238
|
it('returns null from getChannelSnapshot for an unknown channel', async () => {
|
|
231
239
|
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
232
240
|
expect(await rt.getChannelSnapshot('#nope')).toBeNull();
|
|
@@ -614,4 +622,15 @@ describe('InMemoryRuntime — admission gate', () => {
|
|
|
614
622
|
}
|
|
615
623
|
expect(decisions.every((d) => d.ok)).toBe(true);
|
|
616
624
|
});
|
|
625
|
+
|
|
626
|
+
it('returns a stable recordId when admission is disabled (no per-call minting)', () => {
|
|
627
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
628
|
+
const d1 = rt.admitConnection('1.1.1.1', 'alice');
|
|
629
|
+
const d2 = rt.admitConnection('2.2.2.2', 'bob');
|
|
630
|
+
expect(d1.ok).toBe(true);
|
|
631
|
+
expect(d2.ok).toBe(true);
|
|
632
|
+
if (d1.ok && d2.ok) {
|
|
633
|
+
expect(d1.recordId).toBe(d2.recordId);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
617
636
|
});
|
|
@@ -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",
|
|
@@ -34,6 +38,8 @@
|
|
|
34
38
|
},
|
|
35
39
|
"devDependencies": {
|
|
36
40
|
"@stryker-mutator/core": "^9.6.1",
|
|
37
|
-
"@stryker-mutator/vitest-runner": "^9.6.1"
|
|
41
|
+
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
42
|
+
"@types/node": "^26.1.1",
|
|
43
|
+
"vitest": "^4.1.0"
|
|
38
44
|
}
|
|
39
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),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRC case-mapping (case folding) for nick and channel-name comparison.
|
|
3
|
+
*
|
|
4
|
+
* IRC servers compare nicks and channel names case-insensitively, but the
|
|
5
|
+
* exact set of characters that fold together is advertised to clients via
|
|
6
|
+
* the `005 RPL_ISUPPORT` `CASEMAPPING=` token. This module implements the
|
|
7
|
+
* two relevant mappings:
|
|
8
|
+
*
|
|
9
|
+
* - `ascii` — only `A`–`Z` fold to `a`–`z`.
|
|
10
|
+
* - `rfc1459` — `ascii` plus the four characters `[ \ ] ^` folding to
|
|
11
|
+
* `{ | } ~` respectively (per RFC 1459 §2.2). The targets
|
|
12
|
+
* `{ | } ~` do NOT fold further, so the fold is idempotent:
|
|
13
|
+
* `fold(fold(s)) === fold(s)`.
|
|
14
|
+
*
|
|
15
|
+
* The fold operates on one character at a time and leaves every other byte
|
|
16
|
+
* (including all non-ASCII) unchanged. It is pure and dependency-free so it
|
|
17
|
+
* runs unchanged on every runtime (Node, Workers, Lambda).
|
|
18
|
+
*
|
|
19
|
+
* Advertised via {@link CaseMapping} and consumed by the command reducers
|
|
20
|
+
* and the reference runtime so that the advertised case-mapping and the
|
|
21
|
+
* actual folding never diverge.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Case-mapping algorithms supported for IRC-name comparison. */
|
|
25
|
+
export type CaseMapping = 'rfc1459' | 'ascii';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lowercases `s` according to `mapping`, returning the canonical key used
|
|
29
|
+
* for case-insensitive nick and channel comparison.
|
|
30
|
+
*
|
|
31
|
+
* @param mapping `rfc1459` (default behaviour everywhere in this server) or
|
|
32
|
+
* `ascii` (a strict subset — useful for strict-ASCII tests).
|
|
33
|
+
* @param s The nick or channel name to fold.
|
|
34
|
+
*/
|
|
35
|
+
export function caseFold(mapping: CaseMapping, s: string): string {
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
38
|
+
out += foldChar(mapping, s.charCodeAt(i));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Folds a single UTF-16 code unit per the active mapping. */
|
|
44
|
+
function foldChar(mapping: CaseMapping, code: number): string {
|
|
45
|
+
if (code >= 0x41 && code <= 0x5a) {
|
|
46
|
+
// A–Z → a–z
|
|
47
|
+
return String.fromCharCode(code + 0x20);
|
|
48
|
+
}
|
|
49
|
+
if (mapping === 'rfc1459') {
|
|
50
|
+
switch (code) {
|
|
51
|
+
case 0x5b:
|
|
52
|
+
return '{'; // [ → {
|
|
53
|
+
case 0x5c:
|
|
54
|
+
return '|'; // \ → |
|
|
55
|
+
case 0x5d:
|
|
56
|
+
return '}'; // ] → }
|
|
57
|
+
case 0x5e:
|
|
58
|
+
return '~'; // ^ → ~
|
|
59
|
+
default:
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return String.fromCharCode(code);
|
|
64
|
+
}
|
|
@@ -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.
|