serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure helpers exported from `tools/migrate-accounts-to-services.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The helpers are side-effect-free (the CLI entry point is guarded behind
|
|
5
|
+
* `void main()` which only runs when the module is executed directly), so
|
|
6
|
+
* they can be imported and tested without triggering wrangler / DynamoDB
|
|
7
|
+
* IO.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type LegacyAccountRow,
|
|
12
|
+
buildMigrationSql,
|
|
13
|
+
buildServicesPutItem,
|
|
14
|
+
parseSaslAccountsForMigration,
|
|
15
|
+
} from '@serverless-ircd/irc-core';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
describe('parseSaslAccountsForMigration', () => {
|
|
19
|
+
it('returns an empty array for undefined', () => {
|
|
20
|
+
expect(parseSaslAccountsForMigration(undefined)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns an empty array for an empty string', () => {
|
|
24
|
+
expect(parseSaslAccountsForMigration('')).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('parses a single username:password entry', () => {
|
|
28
|
+
expect(parseSaslAccountsForMigration('alice:s3cret')).toEqual([
|
|
29
|
+
{ username: 'alice', password: 's3cret' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('parses multiple newline-delimited entries', () => {
|
|
34
|
+
expect(parseSaslAccountsForMigration('alice:s3cret\nbob:pw')).toEqual([
|
|
35
|
+
{ username: 'alice', password: 's3cret' },
|
|
36
|
+
{ username: 'bob', password: 'pw' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('skips blank lines and malformed entries', () => {
|
|
41
|
+
expect(
|
|
42
|
+
parseSaslAccountsForMigration('alice:s3cret\n\nnocolon\n:emptyuser\nemptypass:\n \n'),
|
|
43
|
+
).toEqual([{ username: 'alice', password: 's3cret' }]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('buildMigrationSql — D1 migration SQL', () => {
|
|
48
|
+
const legacyRow = (account: string, salt = 'salt', hash = 'hash'): LegacyAccountRow => ({
|
|
49
|
+
account,
|
|
50
|
+
algorithm: 'scrypt',
|
|
51
|
+
salt,
|
|
52
|
+
hash,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('(a) creates a nickserv_accounts row with cert_subjects "[]" for a legacy account', () => {
|
|
56
|
+
const stmts = buildMigrationSql([legacyRow('alice')], undefined);
|
|
57
|
+
// First statement is CREATE TABLE.
|
|
58
|
+
expect(stmts[0]).toContain('CREATE TABLE IF NOT EXISTS nickserv_accounts');
|
|
59
|
+
// Second statement is the INSERT OR IGNORE for alice.
|
|
60
|
+
expect(stmts[1]).toContain('INSERT OR IGNORE INTO nickserv_accounts');
|
|
61
|
+
expect(stmts[1]).toContain("'alice'");
|
|
62
|
+
expect(stmts[1]).toContain("'[]'");
|
|
63
|
+
expect(stmts[1]).toContain("'scrypt'");
|
|
64
|
+
expect(stmts[1]).toContain("'salt'");
|
|
65
|
+
expect(stmts[1]).toContain("'hash'");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses INSERT OR IGNORE (never INSERT OR REPLACE) so existing rows are never clobbered', () => {
|
|
69
|
+
const stmts = buildMigrationSql([legacyRow('alice')], undefined);
|
|
70
|
+
expect(stmts[1]).toContain('INSERT OR IGNORE');
|
|
71
|
+
expect(stmts[1]).not.toContain('INSERT OR REPLACE');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('(c) a legacy row never overwrites an existing NickServ registration (INSERT OR IGNORE)', () => {
|
|
75
|
+
// The SQL is INSERT OR IGNORE — if the nick_key already exists in
|
|
76
|
+
// nickserv_accounts, the INSERT is a no-op. The generated SQL encodes
|
|
77
|
+
// this; running it twice produces the same statements.
|
|
78
|
+
const stmts1 = buildMigrationSql([legacyRow('alice')], undefined);
|
|
79
|
+
const stmts2 = buildMigrationSql([legacyRow('alice')], undefined);
|
|
80
|
+
expect(stmts1).toEqual(stmts2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('carries SASL_ACCOUNTS env-seed entries not already in the legacy table', () => {
|
|
84
|
+
const stmts = buildMigrationSql([legacyRow('alice')], 'bob:bobspw\nalice:alicespw');
|
|
85
|
+
// CREATE TABLE + alice (legacy) + bob (env-seed).
|
|
86
|
+
// alice is in the legacy table so the env-seed alice is NOT duplicated.
|
|
87
|
+
expect(stmts).toHaveLength(3);
|
|
88
|
+
expect(stmts[1]).toContain("'alice'");
|
|
89
|
+
expect(stmts[1]).toContain("'salt'");
|
|
90
|
+
expect(stmts[2]).toContain("'bob'");
|
|
91
|
+
// bob's credential is freshly hashed from the plaintext password.
|
|
92
|
+
expect(stmts[2]).not.toContain("'bobspw'");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not re-hash legacy rows (preserves the pre-hashed scrypt credential verbatim)', () => {
|
|
96
|
+
const stmts = buildMigrationSql([legacyRow('alice', 'mysalt', 'myhash')], undefined);
|
|
97
|
+
expect(stmts[1]).toContain("'mysalt'");
|
|
98
|
+
expect(stmts[1]).toContain("'myhash'");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('produces only CREATE TABLE when both legacy rows and env-seed are empty', () => {
|
|
102
|
+
const stmts = buildMigrationSql([], undefined);
|
|
103
|
+
expect(stmts).toHaveLength(1);
|
|
104
|
+
expect(stmts[0]).toContain('CREATE TABLE');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('escapes single quotes in usernames to prevent SQL injection', () => {
|
|
108
|
+
const stmts = buildMigrationSql([legacyRow("O'Brien")], undefined);
|
|
109
|
+
expect(stmts[1]).toContain("'O''Brien'");
|
|
110
|
+
expect(stmts[1]).not.toContain("'O'Brien'");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('buildServicesPutItem — DynamoDB migration', () => {
|
|
115
|
+
it('targets the NICK:<fold> partition with the nick-type discriminator', () => {
|
|
116
|
+
const input = buildServicesPutItem(
|
|
117
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
118
|
+
'StagingServices',
|
|
119
|
+
);
|
|
120
|
+
expect(input.TableName).toBe('StagingServices');
|
|
121
|
+
expect(input.Item.pk).toBe('NICK:alice');
|
|
122
|
+
expect(input.Item.sk).toBe('#');
|
|
123
|
+
expect(input.Item.type).toBe('nick');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('preserves the legacy scrypt credential fields on the item', () => {
|
|
127
|
+
const input = buildServicesPutItem(
|
|
128
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 'mysalt', hash: 'myhash' },
|
|
129
|
+
'Services',
|
|
130
|
+
);
|
|
131
|
+
expect(input.Item.algorithm).toBe('scrypt');
|
|
132
|
+
expect(input.Item.salt).toBe('mysalt');
|
|
133
|
+
expect(input.Item.hash).toBe('myhash');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('defaults certSubjects to an empty array', () => {
|
|
137
|
+
const input = buildServicesPutItem(
|
|
138
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
139
|
+
'Services',
|
|
140
|
+
);
|
|
141
|
+
expect(input.Item.certSubjects).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('folds the nick case-insensitively into the partition key', () => {
|
|
145
|
+
const input = buildServicesPutItem(
|
|
146
|
+
{ account: 'Alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
147
|
+
'Services',
|
|
148
|
+
);
|
|
149
|
+
// rfc1459 folding: Alice → alice
|
|
150
|
+
expect(input.Item.pk).toBe('NICK:alice');
|
|
151
|
+
expect(input.Item.nick).toBe('Alice');
|
|
152
|
+
expect(input.Item.account).toBe('Alice');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('stamps the default email / enforce / createdAt fields', () => {
|
|
156
|
+
const input = buildServicesPutItem(
|
|
157
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
158
|
+
'Services',
|
|
159
|
+
);
|
|
160
|
+
expect(input.Item.email).toBe('');
|
|
161
|
+
expect(input.Item.enforce).toBe('none');
|
|
162
|
+
expect(input.Item.createdAt).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
12
|
+
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
12
13
|
import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
|
|
13
14
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
14
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
16
|
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
16
17
|
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
17
18
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
@@ -164,6 +165,33 @@ describe.skipIf(!available)('NLB stream handler — byte framing', () => {
|
|
|
164
165
|
expect(info?.registration).toBe('pre-registration');
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
it('reads the Connections row strongly consistently on every chunk', async () => {
|
|
169
|
+
// The NLB handler has no $connect event — it writes its own row on the
|
|
170
|
+
// first chunk and reads it back on every subsequent chunk. An eventually-
|
|
171
|
+
// consistent Get can miss the just-written row under replication lag and
|
|
172
|
+
// drop the first frame(s) of a raw-IRC-over-:6697 connection. The Get
|
|
173
|
+
// MUST be strongly consistent.
|
|
174
|
+
const f = requireFx();
|
|
175
|
+
const sendSpy = vi.spyOn(f.client, 'send');
|
|
176
|
+
await handleNlbStream(nlbEvent('10.0.0.50', '50050', 'PING :tok\r\n'), {
|
|
177
|
+
dynamo: f.client,
|
|
178
|
+
tables: f.tables,
|
|
179
|
+
serverConfig: SERVER_CONFIG,
|
|
180
|
+
motd: MOTD,
|
|
181
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
182
|
+
managementApi: f.mgmt,
|
|
183
|
+
});
|
|
184
|
+
const conGets = sendSpy.mock.calls
|
|
185
|
+
.map((call) => call[0] as unknown)
|
|
186
|
+
.filter(
|
|
187
|
+
(cmd): cmd is GetCommand =>
|
|
188
|
+
cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
|
|
189
|
+
);
|
|
190
|
+
expect(conGets.length).toBeGreaterThan(0);
|
|
191
|
+
expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
|
|
192
|
+
sendSpy.mockRestore();
|
|
193
|
+
});
|
|
194
|
+
|
|
167
195
|
it('returns a PONG in the response body for a single PING', async () => {
|
|
168
196
|
const f = requireFx();
|
|
169
197
|
// First chunk establishes the connection.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `$connect` Origin-allowlist policy
|
|
3
|
+
* (`packages/aws-adapter/src/origin-allowlist.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the canonical CF Worker policy
|
|
6
|
+
* (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing semantics
|
|
7
|
+
* (comma-separated, trimmed, lowercased) and the "missing Origin =
|
|
8
|
+
* non-browser → allow" branch. Deliberately diverges from CF by
|
|
9
|
+
* supporting ONLY the explicit-allowlist mode (the AWS SPA is served
|
|
10
|
+
* from a different CloudFront origin than the API Gateway WebSocket
|
|
11
|
+
* endpoint, so same-origin auto-derive does not apply); see the
|
|
12
|
+
* module docstring for the rationale.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { decideConnectOrigin, parseWebOrigins } from '../src/origin-allowlist.js';
|
|
16
|
+
|
|
17
|
+
describe('parseWebOrigins', () => {
|
|
18
|
+
it('returns an empty set for undefined input', () => {
|
|
19
|
+
expect(parseWebOrigins(undefined).size).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns an empty set for an empty string', () => {
|
|
23
|
+
expect(parseWebOrigins('').size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('parses a single origin', () => {
|
|
27
|
+
expect(parseWebOrigins('https://app.example.com')).toEqual(
|
|
28
|
+
new Set(['https://app.example.com']),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('parses multiple comma-separated origins', () => {
|
|
33
|
+
expect(parseWebOrigins('https://a.example.com,https://b.example.com')).toEqual(
|
|
34
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('trims whitespace around each entry', () => {
|
|
39
|
+
expect(parseWebOrigins(' https://a.example.com , https://b.example.com ')).toEqual(
|
|
40
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('lowercases each entry so the match is case-insensitive on scheme/host', () => {
|
|
45
|
+
// Origins are scheme://host[:port]; scheme and host are case-insensitive
|
|
46
|
+
// per RFC 3986. Lowercasing the whole string is the simplest
|
|
47
|
+
// normalisation that matches the real browser-emitted form.
|
|
48
|
+
expect(parseWebOrigins('https://APP.Example.COM')).toEqual(
|
|
49
|
+
new Set(['https://app.example.com']),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drops empty entries (trailing comma tolerance)', () => {
|
|
54
|
+
expect(parseWebOrigins('https://a.example.com,,')).toEqual(new Set(['https://a.example.com']));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('drops whitespace-only entries', () => {
|
|
58
|
+
expect(parseWebOrigins('https://a.example.com, ,https://b.example.com')).toEqual(
|
|
59
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('decideConnectOrigin', () => {
|
|
65
|
+
it('allows a missing Origin header (non-browser client)', () => {
|
|
66
|
+
// curl, WeeChat, and the tcp-ws-forwarder never send `Origin`;
|
|
67
|
+
// their upgrades must pass through unchanged so existing
|
|
68
|
+
// non-browser tooling keeps working.
|
|
69
|
+
expect(decideConnectOrigin(undefined, new Set(['https://app.example.com']))).toBe('allow');
|
|
70
|
+
expect(decideConnectOrigin(null, new Set(['https://app.example.com']))).toBe('allow');
|
|
71
|
+
expect(decideConnectOrigin('', new Set(['https://app.example.com']))).toBe('allow');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('allows every Origin when the allowlist is empty (opt-in defense)', () => {
|
|
75
|
+
// Unset `WEB_ORIGINS` skips the check entirely — existing AWS
|
|
76
|
+
// deployments without a web frontend are unchanged on upgrade.
|
|
77
|
+
expect(decideConnectOrigin('https://attacker.example.com', new Set())).toBe('allow');
|
|
78
|
+
expect(decideConnectOrigin('https://app.example.com', new Set())).toBe('allow');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('allows an Origin that is in the allowlist (case-insensitively)', () => {
|
|
82
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
83
|
+
expect(decideConnectOrigin('https://app.example.com', allowlist)).toBe('allow');
|
|
84
|
+
// The header value is normalised (lowercased, trimmed) before the
|
|
85
|
+
// membership test, so a mixed-case header matches a lowercased entry.
|
|
86
|
+
expect(decideConnectOrigin('https://APP.Example.COM', allowlist)).toBe('allow');
|
|
87
|
+
expect(decideConnectOrigin(' https://app.example.com ', allowlist)).toBe('allow');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('denies an Origin that is not in the allowlist', () => {
|
|
91
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
92
|
+
expect(decideConnectOrigin('https://attacker.example.com', allowlist)).toBe('deny');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('denies an Origin that matches a listed origin except for the scheme', () => {
|
|
96
|
+
// Origins are case-sensitive on scheme+host+port; `http://` is a
|
|
97
|
+
// different origin from `https://` and MUST NOT be admitted when
|
|
98
|
+
// only the `https://` form is allowlisted (defends against a
|
|
99
|
+
// stripped-TLS attacker on the same hostname).
|
|
100
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
101
|
+
expect(decideConnectOrigin('http://app.example.com', allowlist)).toBe('deny');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('matches one of several allowlisted origins', () => {
|
|
105
|
+
const allowlist = parseWebOrigins('https://app.example.com, https://staging.app.example.com');
|
|
106
|
+
expect(decideConnectOrigin('https://staging.app.example.com', allowlist)).toBe('allow');
|
|
107
|
+
expect(decideConnectOrigin('https://app.example.com', allowlist)).toBe('allow');
|
|
108
|
+
expect(decideConnectOrigin('https://other.example.com', allowlist)).toBe('deny');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -238,7 +238,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
238
238
|
ChannelMeta: 'ChannelMeta',
|
|
239
239
|
ChannelMembers: 'ChannelMembers',
|
|
240
240
|
Nicks: 'Nicks',
|
|
241
|
-
Accounts: 'Accounts',
|
|
242
241
|
};
|
|
243
242
|
// Pass `undefined` so the stub returns `{}` — no Items key — to
|
|
244
243
|
// exercise the `?? []` fallback on the Connections scan, and
|
|
@@ -258,7 +257,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
258
257
|
ChannelMeta: 'ChannelMeta',
|
|
259
258
|
ChannelMembers: 'ChannelMembers',
|
|
260
259
|
Nicks: 'Nicks',
|
|
261
|
-
Accounts: 'Accounts',
|
|
262
260
|
};
|
|
263
261
|
const dynamo = stubDynamo([], 0);
|
|
264
262
|
const stats = new AwsStats({
|
|
@@ -279,7 +277,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
279
277
|
ChannelMeta: 'ChannelMeta',
|
|
280
278
|
ChannelMembers: 'ChannelMembers',
|
|
281
279
|
Nicks: 'Nicks',
|
|
282
|
-
Accounts: 'Accounts',
|
|
283
280
|
};
|
|
284
281
|
// Two registered connection rows.
|
|
285
282
|
const items = [
|
|
@@ -18,6 +18,7 @@ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
19
|
import { cleanupConnection } from '../src/aws-runtime.js';
|
|
20
20
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
21
|
+
import { CONNECTION_COUNT_META_ID } from '../src/connection-counter.js';
|
|
21
22
|
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
22
23
|
import { type SweepParams, type SweepResult, handleSweep } from '../src/handlers/sweeper.js';
|
|
23
24
|
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
@@ -392,7 +393,6 @@ function fakeSweepParams(client: FakeScanDynamo): SweepParams {
|
|
|
392
393
|
ChannelMeta: 'CM',
|
|
393
394
|
ChannelMembers: 'CMem',
|
|
394
395
|
Nicks: 'N',
|
|
395
|
-
Accounts: 'A',
|
|
396
396
|
},
|
|
397
397
|
now: 10_000,
|
|
398
398
|
staleAfterMs: 1_000,
|
|
@@ -424,4 +424,23 @@ describe('gone-connection sweeper — scan edge cases', () => {
|
|
|
424
424
|
expect(result.scanned).toBe(1);
|
|
425
425
|
expect(result.swept).toBe(0);
|
|
426
426
|
});
|
|
427
|
+
|
|
428
|
+
it('skips the admission-counter meta row (never swept, never counted)', async () => {
|
|
429
|
+
// The atomic admission counter lives in the Connections table under a
|
|
430
|
+
// sentinel id. The sweeper must not treat it as a stale connection —
|
|
431
|
+
// it carries no idleSince and must never be torn down by the sweeper.
|
|
432
|
+
const client = new FakeScanDynamo([
|
|
433
|
+
{
|
|
434
|
+
items: [
|
|
435
|
+
{ connectionId: CONNECTION_COUNT_META_ID, count: 7 },
|
|
436
|
+
{ connectionId: 'real', idleSince: 0 },
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
]);
|
|
440
|
+
const result = await handleSweep(fakeSweepParams(client));
|
|
441
|
+
// The meta row is excluded from both `scanned` and `swept`; only the
|
|
442
|
+
// genuine stale connection is counted.
|
|
443
|
+
expect(result.scanned).toBe(1);
|
|
444
|
+
expect(result.swept).toBe(1);
|
|
445
|
+
});
|
|
427
446
|
});
|
|
@@ -13,13 +13,12 @@ import {
|
|
|
13
13
|
} from '../src/tables.js';
|
|
14
14
|
|
|
15
15
|
describe('TABLE_NAMES', () => {
|
|
16
|
-
it('contains exactly the
|
|
16
|
+
it('contains exactly the five canonical logical names', () => {
|
|
17
17
|
expect(TABLE_NAMES).toEqual([
|
|
18
18
|
'Connections',
|
|
19
19
|
'ChannelMeta',
|
|
20
20
|
'ChannelMembers',
|
|
21
21
|
'Nicks',
|
|
22
|
-
'Accounts',
|
|
23
22
|
'Services',
|
|
24
23
|
]);
|
|
25
24
|
});
|
|
@@ -46,10 +45,6 @@ describe('TABLE_KEYS', () => {
|
|
|
46
45
|
it('exposes the nickLower PK for Nicks', () => {
|
|
47
46
|
expect(TABLE_KEYS.Nicks.pk).toBe('nickLower');
|
|
48
47
|
});
|
|
49
|
-
|
|
50
|
-
it('exposes the account PK for Accounts', () => {
|
|
51
|
-
expect(TABLE_KEYS.Accounts.pk).toBe('account');
|
|
52
|
-
});
|
|
53
48
|
});
|
|
54
49
|
|
|
55
50
|
describe('tablesConfigFromNames', () => {
|
|
@@ -59,7 +54,6 @@ describe('tablesConfigFromNames', () => {
|
|
|
59
54
|
ChannelMeta: 'p-ChannelMeta',
|
|
60
55
|
ChannelMembers: 'p-ChannelMembers',
|
|
61
56
|
Nicks: 'p-Nicks',
|
|
62
|
-
Accounts: 'p-Accounts',
|
|
63
57
|
};
|
|
64
58
|
expect(tablesConfigFromNames(names)).toEqual(names);
|
|
65
59
|
});
|
|
@@ -70,7 +64,6 @@ describe('tablesConfigFromNames', () => {
|
|
|
70
64
|
ChannelMeta: 'cm',
|
|
71
65
|
ChannelMembers: 'cml',
|
|
72
66
|
Nicks: 'n',
|
|
73
|
-
Accounts: 'a',
|
|
74
67
|
};
|
|
75
68
|
const out = tablesConfigFromNames(names);
|
|
76
69
|
expect(out).not.toBe(names);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
|
-
},
|
|
15
|
-
"./accounts": {
|
|
16
|
-
"types": "./dist/d1-account-store.d.ts",
|
|
17
|
-
"import": "./dist/d1-account-store.js"
|
|
18
14
|
}
|
|
19
15
|
},
|
|
20
16
|
"scripts": {
|
|
@@ -181,10 +181,11 @@ export class CfRuntime implements IrcRuntime {
|
|
|
181
181
|
if (connId === except || connId === this.connId) return;
|
|
182
182
|
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(connId));
|
|
183
183
|
const dto = await (stub as unknown as ConnectionRpc).getConnState();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
await this.deliverWallopsToCandidate(
|
|
185
|
+
stub as unknown as { deliver(lines: RawLine[]): Promise<unknown> },
|
|
186
|
+
dto,
|
|
187
|
+
lines,
|
|
188
|
+
);
|
|
188
189
|
}),
|
|
189
190
|
);
|
|
190
191
|
}
|
|
@@ -284,7 +285,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
284
285
|
}
|
|
285
286
|
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
|
|
286
287
|
const dto = await (stub as unknown as ConnectionRpc).getConnState();
|
|
287
|
-
return
|
|
288
|
+
return this.deserializeConnState(dto);
|
|
288
289
|
}
|
|
289
290
|
|
|
290
291
|
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
@@ -294,9 +295,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
294
295
|
memberIds.map(async (id) => {
|
|
295
296
|
const connStub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(id));
|
|
296
297
|
const dto = await (connStub as unknown as ConnectionRpc).getConnState();
|
|
297
|
-
|
|
298
|
-
out.set(id, deserialize(dto));
|
|
299
|
-
}
|
|
298
|
+
this.collectChannelMember(out, id, dto);
|
|
300
299
|
}),
|
|
301
300
|
);
|
|
302
301
|
return out;
|
|
@@ -320,6 +319,58 @@ export class CfRuntime implements IrcRuntime {
|
|
|
320
319
|
return loadServerConfigFromCfEnv(this.env as unknown as CfConfigEnv);
|
|
321
320
|
}
|
|
322
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Phase 2 of {@link broadcastWallops}: rehydrates the candidate's
|
|
324
|
+
* persisted state, gates on user mode `+w`, and dispatches `lines`
|
|
325
|
+
* via the target stub's `deliver` RPC. Extracted as a public test
|
|
326
|
+
* seam because every real ConnectionDO's `getConnState` always
|
|
327
|
+
* returns a non-null DTO (the storage layer is the source of truth
|
|
328
|
+
* and always has an entry post-`loadState`), so the `dto === null`
|
|
329
|
+
* and `!+w` defensive arms are unreachable through end-to-end RPC.
|
|
330
|
+
*
|
|
331
|
+
* Behavioural contract:
|
|
332
|
+
* - `dto === null` → no-op (candidate reported no state).
|
|
333
|
+
* - `dto` with `+w` off → no-op (recipient gate fails).
|
|
334
|
+
* - `dto` with `+w` on → `stub.deliver(lines)` awaited.
|
|
335
|
+
*/
|
|
336
|
+
public async deliverWallopsToCandidate(
|
|
337
|
+
stub: { deliver(lines: RawLine[]): Promise<unknown> },
|
|
338
|
+
dto: PersistedConnectionState | null,
|
|
339
|
+
lines: RawLine[],
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
if (dto === null) return;
|
|
342
|
+
const state = deserialize(dto);
|
|
343
|
+
if (!state.userModes.wallops) return;
|
|
344
|
+
await stub.deliver(lines);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Rehydrates a cross-connection DTO into a live {@link ConnectionState},
|
|
349
|
+
* returning `null` when the candidate reported no persisted state.
|
|
350
|
+
* Extracted as a public test seam because real ConnectionDOs always
|
|
351
|
+
* return a non-null DTO from `getConnState`, so the `dto === null`
|
|
352
|
+
* arm is unreachable through end-to-end RPC.
|
|
353
|
+
*/
|
|
354
|
+
public deserializeConnState(dto: PersistedConnectionState | null): ConnectionState | null {
|
|
355
|
+
return dto === null ? null : deserialize(dto);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Per-member collector for {@link getChannelConnections}: rehydrates the
|
|
360
|
+
* DTO and adds it to `out` keyed by `id`. Extracted so the null-DTO arm
|
|
361
|
+
* (a member whose ConnectionDO reports no live state) is testable
|
|
362
|
+
* directly without constructing a DO whose `getConnState` always
|
|
363
|
+
* returns non-null.
|
|
364
|
+
*/
|
|
365
|
+
public collectChannelMember(
|
|
366
|
+
out: Map<ConnId, ConnectionState>,
|
|
367
|
+
id: ConnId,
|
|
368
|
+
dto: PersistedConnectionState | null,
|
|
369
|
+
): void {
|
|
370
|
+
if (dto === null) return;
|
|
371
|
+
out.set(id, deserialize(dto));
|
|
372
|
+
}
|
|
373
|
+
|
|
323
374
|
// -------------------------------------------------------------------------
|
|
324
375
|
// Internal
|
|
325
376
|
// -------------------------------------------------------------------------
|
|
@@ -63,7 +63,7 @@ interface PersistedChannelState {
|
|
|
63
63
|
modes: ChannelState['modes'];
|
|
64
64
|
banMasks: string[];
|
|
65
65
|
topic?: ChannelTopic;
|
|
66
|
-
members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId }>;
|
|
66
|
+
members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId; op?: boolean; voice?: boolean }>;
|
|
67
67
|
pendingInvites: string[];
|
|
68
68
|
createdAt: number;
|
|
69
69
|
}
|
|
@@ -370,7 +370,13 @@ function serializeChannel(chan: ChannelState): PersistedChannelState {
|
|
|
370
370
|
return out;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Rebuilds a live {@link ChannelState} from a JSON-friendly persisted
|
|
375
|
+
* record. Exported so the legacy-shape defensive arms (`op`/`voice`
|
|
376
|
+
* coalescing, missing-topic branch) are unit-testable without driving
|
|
377
|
+
* a full DO storage round-trip.
|
|
378
|
+
*/
|
|
379
|
+
export function deserializeChannel(record: PersistedChannelState): ChannelState {
|
|
374
380
|
const chan: ChannelState = {
|
|
375
381
|
name: record.name,
|
|
376
382
|
nameLower: record.nameLower,
|
|
@@ -393,8 +399,12 @@ function deserializeChannel(record: PersistedChannelState): ChannelState {
|
|
|
393
399
|
* Builds a JSON-friendly {@link ChannelSnapshotDto} from a live
|
|
394
400
|
* {@link ChannelState}. Uses {@link toSnapshot} for the immutable
|
|
395
401
|
* view, then flattens the `Map`/`Set` fields to arrays.
|
|
402
|
+
*
|
|
403
|
+
* Exported so the defensive `?:` arms (which fire when a state was
|
|
404
|
+
* built outside {@link createChannel} and lacks the `Set`/`Map`
|
|
405
|
+
* fields) are unit-testable directly.
|
|
396
406
|
*/
|
|
397
|
-
function toDto(chan: ChannelState): ChannelSnapshotDto {
|
|
407
|
+
export function toDto(chan: ChannelState): ChannelSnapshotDto {
|
|
398
408
|
// Defensively rebuild arrays; in some test-pool code paths the cached
|
|
399
409
|
// state's Set/Map fields may be missing if the channel was created
|
|
400
410
|
// via an unusual path (e.g. direct storage put without going through
|