serverless-ircd 0.9.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.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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
- '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, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
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 migration for the `algorithm/salt/hash` unification
652
- * (commit cfa5a3c). `CREATE TABLE IF NOT EXISTS` does NOT add columns to
653
- * an existing table, so a D1 first provisioned by an older deploy keeps
654
- * the original `nickserv_accounts (... enforce, password)` schema. Every
655
- * current SELECT/INSERT references `algorithm`, so reads throw
656
- * "no such column: algorithm" (masked by `safeSelect` as an empty
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
- * Detection: `PRAGMA table_info`. If `nickserv_accounts` lacks
660
- * `algorithm`, it is the pre-unification schema. Those rows stored a
661
- * non-scrypt `password` the current `verifyNick` cannot validate, so the
662
- * table is dropped and recreated with the current schema (data loss is
663
- * intentional and limited to already-unusable credentials). Idempotent:
664
- * once the new schema is in place, `algorithm` is present and this is a
665
- * no-op.
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')) return;
673
- // Old schema detected (has `password`, lacks `algorithm`). The table is
674
- // guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
675
- // EXISTS); recreate it with the current schema.
676
- await db.batch([
677
- db.prepare('DROP TABLE nickserv_accounts'),
678
- db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
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 SASL account credentials. The `accounts`
51
- * table holds scrypt-hashed rows (`{ account PK, algorithm, salt, hash }`),
52
- * loaded at {@link ConnectionDO} construction by `loadD1AccountStore`.
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 so deployments that have not migrated to D1 keep working: when
55
- * unset, the precedence resolver falls straight through to the
56
- * `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
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 seeded into the in-worker `AccountStore`.
75
- * Newline-delimited `username:password` pairs; the connection-do parses
76
- * these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
77
- * end-to-end. Empty/undefined disables SASL account verification.
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 { ChannelSnapshotDto } from '../src/channel-do';
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
+ });