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
|
@@ -78,7 +78,7 @@ interface D1QueryClient {
|
|
|
78
78
|
* the list on a warm deploy is a no-op.
|
|
79
79
|
*/
|
|
80
80
|
export const CREATE_NICKSERV_ACCOUNTS_SQL =
|
|
81
|
-
|
|
81
|
+
"CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
82
82
|
|
|
83
83
|
export const CREATE_SERVICES_TABLES_SQL: readonly string[] = [
|
|
84
84
|
CREATE_NICKSERV_ACCOUNTS_SQL,
|
|
@@ -136,7 +136,7 @@ export class D1ServicesStore extends PersistentServicesStore {
|
|
|
136
136
|
return [
|
|
137
137
|
this.db
|
|
138
138
|
.prepare(
|
|
139
|
-
'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
139
|
+
'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
140
140
|
)
|
|
141
141
|
.bind(
|
|
142
142
|
fold(r.nick),
|
|
@@ -145,6 +145,7 @@ export class D1ServicesStore extends PersistentServicesStore {
|
|
|
145
145
|
r.email,
|
|
146
146
|
r.createdAt,
|
|
147
147
|
r.enforce,
|
|
148
|
+
JSON.stringify(r.certSubjects ?? []),
|
|
148
149
|
r.credential.algorithm,
|
|
149
150
|
r.credential.salt,
|
|
150
151
|
r.credential.hash,
|
|
@@ -361,12 +362,13 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
361
362
|
email: unknown;
|
|
362
363
|
created_at: unknown;
|
|
363
364
|
enforce: unknown;
|
|
365
|
+
cert_subjects: unknown;
|
|
364
366
|
algorithm: unknown;
|
|
365
367
|
salt: unknown;
|
|
366
368
|
hash: unknown;
|
|
367
369
|
}>(
|
|
368
370
|
db,
|
|
369
|
-
'SELECT nick, account, email, created_at, enforce, algorithm, salt, hash FROM nickserv_accounts',
|
|
371
|
+
'SELECT nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash FROM nickserv_accounts',
|
|
370
372
|
);
|
|
371
373
|
const out: ServicesNickRow[] = [];
|
|
372
374
|
for (const r of rows) {
|
|
@@ -388,6 +390,7 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
388
390
|
email: r.email,
|
|
389
391
|
createdAt: r.created_at,
|
|
390
392
|
enforce: r.enforce as NickEnforcePolicy,
|
|
393
|
+
certSubjects: parseCertSubjects(r.cert_subjects),
|
|
391
394
|
credential: {
|
|
392
395
|
account: r.account,
|
|
393
396
|
algorithm: 'scrypt',
|
|
@@ -399,6 +402,23 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
399
402
|
return out;
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Parses the `cert_subjects` TEXT column (JSON-encoded array) into a
|
|
407
|
+
* `string[]`. Tolerates `undefined` (a row written before the column
|
|
408
|
+
* existed), `null`, or malformed JSON — all collapse to `[]` so a stale or
|
|
409
|
+
* partially-migrated row loads cleanly.
|
|
410
|
+
*/
|
|
411
|
+
function parseCertSubjects(raw: unknown): string[] {
|
|
412
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
413
|
+
try {
|
|
414
|
+
const parsed = JSON.parse(raw);
|
|
415
|
+
if (!Array.isArray(parsed)) return [];
|
|
416
|
+
return parsed.filter((s): s is string => typeof s === 'string');
|
|
417
|
+
} catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
402
422
|
async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
|
|
403
423
|
const rows = await safeSelect<{
|
|
404
424
|
channel: unknown;
|
|
@@ -603,8 +623,13 @@ async function loadJupes(db: D1QueryClient): Promise<JupeEntry[]> {
|
|
|
603
623
|
* table does not exist yet (fresh deploy) or the binding is unreachable.
|
|
604
624
|
* Silent-skip mirrors the DynamoDB loader: a missing table is treated as
|
|
605
625
|
* an empty section rather than a fatal boot error.
|
|
626
|
+
*
|
|
627
|
+
* Exported so the catch-block fallback is unit-testable directly with a
|
|
628
|
+
* throwing D1 stub (the real binding does not fail on queries that target
|
|
629
|
+
* existing tables, so the catch arm is unreachable through integration
|
|
630
|
+
* tests).
|
|
606
631
|
*/
|
|
607
|
-
async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
|
|
632
|
+
export async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
|
|
608
633
|
try {
|
|
609
634
|
const result = await db.prepare(sql).all<R>();
|
|
610
635
|
return result.results;
|
|
@@ -648,35 +673,47 @@ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
|
|
|
648
673
|
}
|
|
649
674
|
|
|
650
675
|
/**
|
|
651
|
-
* One-time schema
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* snapshot) and writes throw inside `flush()` (after the success NOTICE).
|
|
676
|
+
* One-time schema migrations for `nickserv_accounts`.
|
|
677
|
+
*
|
|
678
|
+
* `CREATE TABLE IF NOT EXISTS` does NOT add columns to an existing table,
|
|
679
|
+
* so a D1 first provisioned by an older deploy keeps the original schema.
|
|
680
|
+
* Two additive migrations run idempotently, each gated on `PRAGMA
|
|
681
|
+
* table_info`:
|
|
658
682
|
*
|
|
659
|
-
*
|
|
660
|
-
* `algorithm`, it
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* intentional and limited to already-unusable credentials).
|
|
664
|
-
*
|
|
665
|
-
*
|
|
683
|
+
* 1. **algorithm/salt/hash unification** (commit cfa5a3c). If the table
|
|
684
|
+
* lacks `algorithm`, it has the pre-unification `password` column
|
|
685
|
+
* whose credentials the current `verifyNick` cannot validate. The
|
|
686
|
+
* table is dropped and recreated with the current schema (data loss
|
|
687
|
+
* is intentional and limited to already-unusable credentials).
|
|
688
|
+
* 2. **cert_subjects**. If the table has `algorithm` but lacks
|
|
689
|
+
* `cert_subjects`, the column is added with `NOT NULL DEFAULT '[]'`
|
|
690
|
+
* so existing rows load with an empty cert-subject list. No data
|
|
691
|
+
* loss — the column is additive.
|
|
666
692
|
*/
|
|
667
693
|
async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
668
694
|
const nickservInfo = await db
|
|
669
695
|
.prepare('PRAGMA table_info(nickserv_accounts)')
|
|
670
696
|
.all<{ name: string }>();
|
|
671
697
|
const nickservCols = new Set(nickservInfo.results.map((r) => r.name));
|
|
672
|
-
if (nickservCols.has('algorithm'))
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
db.
|
|
678
|
-
|
|
679
|
-
|
|
698
|
+
if (!nickservCols.has('algorithm')) {
|
|
699
|
+
// Old schema detected (has `password`, lacks `algorithm`). The table is
|
|
700
|
+
// guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
|
|
701
|
+
// EXISTS); recreate it with the current schema (which includes
|
|
702
|
+
// cert_subjects).
|
|
703
|
+
await db.batch([
|
|
704
|
+
db.prepare('DROP TABLE nickserv_accounts'),
|
|
705
|
+
db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
|
|
706
|
+
]);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
// algorithm present — check for the additive cert_subjects column.
|
|
710
|
+
if (!nickservCols.has('cert_subjects')) {
|
|
711
|
+
await db.batch([
|
|
712
|
+
db.prepare(
|
|
713
|
+
"ALTER TABLE nickserv_accounts ADD COLUMN cert_subjects TEXT NOT NULL DEFAULT '[]'",
|
|
714
|
+
),
|
|
715
|
+
]);
|
|
716
|
+
}
|
|
680
717
|
}
|
|
681
718
|
|
|
682
719
|
export async function loadD1ServicesStore(
|
|
@@ -47,13 +47,13 @@ export interface Env {
|
|
|
47
47
|
*/
|
|
48
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
49
|
/**
|
|
50
|
-
* D1 database backing persistent
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* D1 database backing persistent services (NickServ / ChanServ / HostServ /
|
|
51
|
+
* MemoServ / OperServ + the unified SASL credential store). The
|
|
52
|
+
* `nickserv_accounts` table holds scrypt-hashed credential rows, loaded at
|
|
53
|
+
* {@link ConnectionDO} construction by `loadD1ServicesStore`.
|
|
53
54
|
*
|
|
54
|
-
* Optional
|
|
55
|
-
*
|
|
56
|
-
* `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
|
|
55
|
+
* Optional: when unset, services are unbound (no NickServ routing, no
|
|
56
|
+
* SASL/PASS credential verify).
|
|
57
57
|
*/
|
|
58
58
|
ACCOUNTS_DB?: D1Database;
|
|
59
59
|
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
@@ -71,10 +71,11 @@ export interface Env {
|
|
|
71
71
|
CREATED_AT?: string;
|
|
72
72
|
MOTD_LINES: string;
|
|
73
73
|
/**
|
|
74
|
-
* SASL PLAIN accounts
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* SASL PLAIN seed accounts. Newline-delimited `username:password` pairs;
|
|
75
|
+
* the connection-do ingests these into the `ServicesStore` at boot via
|
|
76
|
+
* `registerNick` (idempotent — existing registrations win) so
|
|
77
|
+
* `AUTHENTICATE PLAIN`, `PASS <nick>:<password>`, and NickServ `IDENTIFY`
|
|
78
|
+
* all verify for the listed accounts.
|
|
78
79
|
*/
|
|
79
80
|
SASL_ACCOUNTS?: string;
|
|
80
81
|
/**
|
|
@@ -26,12 +26,6 @@ export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
|
26
26
|
export { CfStats } from './stats.js';
|
|
27
27
|
export type { CfStatsRuntime } from './stats.js';
|
|
28
28
|
export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
29
|
-
export {
|
|
30
|
-
CREATE_ACCOUNTS_TABLE_SQL,
|
|
31
|
-
loadD1AccountStore,
|
|
32
|
-
putAccountCredential,
|
|
33
|
-
resolveAccountStore,
|
|
34
|
-
} from './d1-account-store.js';
|
|
35
29
|
export {
|
|
36
30
|
CREATE_SERVICES_TABLES_SQL,
|
|
37
31
|
D1ServicesStore,
|
|
@@ -29,9 +29,10 @@ import {
|
|
|
29
29
|
type RawLine,
|
|
30
30
|
createConnection,
|
|
31
31
|
} from '@serverless-ircd/irc-core';
|
|
32
|
-
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
32
|
+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
|
33
33
|
import { type CfConnectionHandlers, CfRuntime } from '../src/cf-runtime';
|
|
34
34
|
import type { Env, RegistryRpc } from '../src/env';
|
|
35
|
+
import { serialize } from '../src/serialize';
|
|
35
36
|
import { registryKeyForNick } from '../src/sharding';
|
|
36
37
|
|
|
37
38
|
declare global {
|
|
@@ -921,3 +922,102 @@ describe('CfRuntime — getConnection and getChannelConnections branches', () =>
|
|
|
921
922
|
member.ws.close();
|
|
922
923
|
});
|
|
923
924
|
});
|
|
925
|
+
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
// Tests — pure helper seams for defensive null-DTO branches
|
|
928
|
+
// ---------------------------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
describe('CfRuntime — defensive DTO helper seams', () => {
|
|
931
|
+
it('deserializeConnState returns null when the DTO is null', () => {
|
|
932
|
+
const stubEnv = envWithChannelStub();
|
|
933
|
+
const rt = new CfRuntime(stubEnv, 'conn-dsz-null', recordingHandlers());
|
|
934
|
+
expect(rt.deserializeConnState(null)).toBeNull();
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('deserializeConnState rehydrates a populated DTO', () => {
|
|
938
|
+
const stubEnv = envWithChannelStub();
|
|
939
|
+
const rt = new CfRuntime(stubEnv, 'conn-dsz-real', recordingHandlers());
|
|
940
|
+
const live = createConnection({ id: 'conn-dsz-real', connectedSince: 0 });
|
|
941
|
+
live.nick = 'dz';
|
|
942
|
+
// Round-trip through serialize so the DTO matches what a real
|
|
943
|
+
// ConnectionDO.getConnState would return.
|
|
944
|
+
const dto = serialize(live);
|
|
945
|
+
const out = rt.deserializeConnState(dto);
|
|
946
|
+
expect(out?.nick).toBe('dz');
|
|
947
|
+
expect(out?.id).toBe('conn-dsz-real');
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('collectChannelMember skips a null DTO without mutating the map', () => {
|
|
951
|
+
const stubEnv = envWithChannelStub();
|
|
952
|
+
const rt = new CfRuntime(stubEnv, 'conn-ccm-null', recordingHandlers());
|
|
953
|
+
const out = new Map();
|
|
954
|
+
rt.collectChannelMember(out, 'ghost', null);
|
|
955
|
+
expect(out.size).toBe(0);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('collectChannelMember rehydrates and inserts a populated DTO', () => {
|
|
959
|
+
const stubEnv = envWithChannelStub();
|
|
960
|
+
const rt = new CfRuntime(stubEnv, 'conn-ccm-real', recordingHandlers());
|
|
961
|
+
const live = createConnection({ id: 'conn-ccm-real', connectedSince: 0 });
|
|
962
|
+
live.nick = 'ccm';
|
|
963
|
+
const out = new Map();
|
|
964
|
+
rt.collectChannelMember(out, 'conn-ccm-real', serialize(live));
|
|
965
|
+
expect(out.size).toBe(1);
|
|
966
|
+
expect(out.get('conn-ccm-real')?.nick).toBe('ccm');
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('deliverWallopsToCandidate is a no-op when the DTO is null', async () => {
|
|
970
|
+
const stubEnv = envWithChannelStub();
|
|
971
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-null', recordingHandlers());
|
|
972
|
+
const deliver = vi.fn();
|
|
973
|
+
await rt.deliverWallopsToCandidate({ deliver }, null, [{ text: 'x' }]);
|
|
974
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it('deliverWallopsToCandidate skips a candidate whose +w mode is off', async () => {
|
|
978
|
+
const stubEnv = envWithChannelStub();
|
|
979
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-now', recordingHandlers());
|
|
980
|
+
const live = createConnection({ id: 'conn-dwc-now', connectedSince: 0 });
|
|
981
|
+
live.userModes.wallops = false;
|
|
982
|
+
const deliver = vi.fn();
|
|
983
|
+
await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [{ text: 'x' }]);
|
|
984
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('deliverWallopsToCandidate delivers to a +w candidate', async () => {
|
|
988
|
+
const stubEnv = envWithChannelStub();
|
|
989
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-yes', recordingHandlers());
|
|
990
|
+
const live = createConnection({ id: 'conn-dwc-yes', connectedSince: 0 });
|
|
991
|
+
live.userModes.wallops = true;
|
|
992
|
+
const calls: RawLine[][] = [];
|
|
993
|
+
const deliver = (lines: RawLine[]): Promise<void> => {
|
|
994
|
+
calls.push(lines);
|
|
995
|
+
return Promise.resolve();
|
|
996
|
+
};
|
|
997
|
+
await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [
|
|
998
|
+
{ text: ':oper!u@h WALLOPS :hi' },
|
|
999
|
+
]);
|
|
1000
|
+
expect(calls).toEqual([[{ text: ':oper!u@h WALLOPS :hi' }]]);
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
// Tests — getChannelSnapshot topic branch (the { topic } arm of the spread)
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
describe('CfRuntime — getChannelSnapshot topic branch', () => {
|
|
1009
|
+
it('rehydrates the topic onto ChanSnapshot when the ChannelDO reports one', async () => {
|
|
1010
|
+
// Existing coverage exercises the `: {}` arm of the topic spread; this
|
|
1011
|
+
// case drives the `{ topic: dto.topic }` arm by applying a topic
|
|
1012
|
+
// through the real ChannelDO and reading it back via CfRuntime.
|
|
1013
|
+
const realEnv = envWithRealDOs();
|
|
1014
|
+
const rt = new CfRuntime(realEnv, 'conn-topic-caller', recordingHandlers());
|
|
1015
|
+
const topic = { text: 'news', setter: 'alice', setAt: 1 };
|
|
1016
|
+
await rt.applyChannelDelta('#topic-branch', {
|
|
1017
|
+
memberships: [{ type: 'add' as const, conn: 'conn-topic-caller', nick: 'alice' }],
|
|
1018
|
+
topic,
|
|
1019
|
+
});
|
|
1020
|
+
const snap = await rt.getChannelSnapshot('#topic-branch');
|
|
1021
|
+
expect(snap?.topic).toEqual(topic);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { env, runInDurableObject } from 'cloudflare:test';
|
|
19
19
|
import type { ChannelDelta, ChannelTopic, ConnId, RosterEntry } from '@serverless-ircd/irc-core';
|
|
20
20
|
import { describe, expect, it } from 'vitest';
|
|
21
|
-
import type
|
|
21
|
+
import { type ChannelSnapshotDto, deserializeChannel, toDto } from '../src/channel-do';
|
|
22
22
|
|
|
23
23
|
declare global {
|
|
24
24
|
namespace Cloudflare {
|
|
@@ -595,3 +595,120 @@ describe('ChannelDO — broadcast + cache edge cases', () => {
|
|
|
595
595
|
await alice.client.close();
|
|
596
596
|
});
|
|
597
597
|
});
|
|
598
|
+
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// Tests — ChannelDO instantiated without a name (idFromString)
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
describe('ChannelDO — id.name fallback', () => {
|
|
604
|
+
it('falls back to ctx.id.toString() when the DO was created via idFromString', async () => {
|
|
605
|
+
// ChannelDOs in production are always keyed by lowercased name
|
|
606
|
+
// (idFromName), but the DO storage-creation path still needs to
|
|
607
|
+
// handle the no-name case. `newUniqueId()` returns a valid hex DO id
|
|
608
|
+
// whose `id.name` is null, forcing the `??` fallback to `toString()`.
|
|
609
|
+
const hexId = env.CHANNEL_DO_REAL.newUniqueId().toString();
|
|
610
|
+
const stub = env.CHANNEL_DO_REAL.get(env.CHANNEL_DO_REAL.idFromString(hexId));
|
|
611
|
+
const rpc = stub as unknown as ChannelRpc;
|
|
612
|
+
await rpc.applyChannelDelta({
|
|
613
|
+
memberships: [{ type: 'add', conn: 'c-id-fallback', nick: 'alice' }],
|
|
614
|
+
});
|
|
615
|
+
const snap = await rpc.getChannelSnapshot();
|
|
616
|
+
// The DO used the hex id as the storage key, so nameLower resolves
|
|
617
|
+
// to the hex string (not a lowercased channel name).
|
|
618
|
+
expect(snap?.nameLower).toBe(hexId);
|
|
619
|
+
expect(snap?.name).toBe(hexId);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Tests — pure deserializeChannel / toDto helpers (legacy-shape arms)
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
describe('deserializeChannel — legacy record defensive arms', () => {
|
|
628
|
+
it('coalesces missing op/voice to false for legacy persisted members', () => {
|
|
629
|
+
// Pre-feature records may have been written without `op`/`voice`
|
|
630
|
+
// fields (or with them explicitly undefined). The loader's `?? false`
|
|
631
|
+
// arms must catch both so the rehydrated RosterEntry always carries
|
|
632
|
+
// a boolean. The PersistedChannelState interface types op/voice as
|
|
633
|
+
// optional, but legacy on-disk rows predate the field — cast through
|
|
634
|
+
// unknown to simulate the storage layer's untyped payload.
|
|
635
|
+
const record = {
|
|
636
|
+
name: '#legacy',
|
|
637
|
+
nameLower: '#legacy',
|
|
638
|
+
modes: {
|
|
639
|
+
inviteOnly: false,
|
|
640
|
+
topicLock: false,
|
|
641
|
+
noExternal: false,
|
|
642
|
+
moderated: false,
|
|
643
|
+
secret: false,
|
|
644
|
+
private: false,
|
|
645
|
+
registered: false,
|
|
646
|
+
blockUnidentified: false,
|
|
647
|
+
moderatedIdent: false,
|
|
648
|
+
},
|
|
649
|
+
banMasks: [],
|
|
650
|
+
members: [{ conn: 'c-1', nick: 'alice' }],
|
|
651
|
+
pendingInvites: [],
|
|
652
|
+
createdAt: 0,
|
|
653
|
+
} as unknown as Parameters<typeof deserializeChannel>[0];
|
|
654
|
+
const chan = deserializeChannel(record);
|
|
655
|
+
const entry = chan.members.get('c-1');
|
|
656
|
+
expect(entry?.op).toBe(false);
|
|
657
|
+
expect(entry?.voice).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('leaves topic undefined when the record carries none', () => {
|
|
661
|
+
// The `if (record.topic !== undefined)` else arm: a record written
|
|
662
|
+
// before any TOPIC was set has no topic field, and deserializeChannel
|
|
663
|
+
// must leave chan.topic undefined.
|
|
664
|
+
const record = {
|
|
665
|
+
name: '#notopic',
|
|
666
|
+
nameLower: '#notopic',
|
|
667
|
+
modes: {
|
|
668
|
+
inviteOnly: false,
|
|
669
|
+
topicLock: false,
|
|
670
|
+
noExternal: false,
|
|
671
|
+
moderated: false,
|
|
672
|
+
secret: false,
|
|
673
|
+
private: false,
|
|
674
|
+
registered: false,
|
|
675
|
+
blockUnidentified: false,
|
|
676
|
+
moderatedIdent: false,
|
|
677
|
+
},
|
|
678
|
+
banMasks: [],
|
|
679
|
+
members: [],
|
|
680
|
+
pendingInvites: [],
|
|
681
|
+
createdAt: 0,
|
|
682
|
+
} as unknown as Parameters<typeof deserializeChannel>[0];
|
|
683
|
+
expect(deserializeChannel(record).topic).toBeUndefined();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
describe('toDto — defensive Set/Map arms', () => {
|
|
688
|
+
it('returns empty arrays when ChannelState fields are missing', () => {
|
|
689
|
+
// Channels constructed without going through createChannel (e.g. a
|
|
690
|
+
// hand-built test fixture or a future storage migration) may lack
|
|
691
|
+
// the `Set`/`Map` fields. The `?:` arms in toDto must degrade to
|
|
692
|
+
// empty arrays so the DTO never carries undefined to callers.
|
|
693
|
+
const partial = {
|
|
694
|
+
name: '#partial',
|
|
695
|
+
nameLower: '#partial',
|
|
696
|
+
modes: {
|
|
697
|
+
inviteOnly: false,
|
|
698
|
+
topicLock: false,
|
|
699
|
+
noExternal: false,
|
|
700
|
+
moderated: false,
|
|
701
|
+
secret: false,
|
|
702
|
+
private: false,
|
|
703
|
+
registered: false,
|
|
704
|
+
blockUnidentified: false,
|
|
705
|
+
moderatedIdent: false,
|
|
706
|
+
},
|
|
707
|
+
createdAt: 0,
|
|
708
|
+
} as unknown as Parameters<typeof toDto>[0];
|
|
709
|
+
const dto = toDto(partial);
|
|
710
|
+
expect(dto.banMasks).toEqual([]);
|
|
711
|
+
expect(dto.members).toEqual([]);
|
|
712
|
+
expect(dto.pendingInvites).toEqual([]);
|
|
713
|
+
});
|
|
714
|
+
});
|