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
@@ -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
+ });
@@ -490,7 +490,6 @@ function fakeParams(client: FakeScanDynamo): PingCheckParams {
490
490
  ChannelMeta: 'CM',
491
491
  ChannelMembers: 'CMem',
492
492
  Nicks: 'N',
493
- Accounts: 'A',
494
493
  },
495
494
  managementApi: null,
496
495
  now: 100_000,
@@ -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 = [
@@ -393,7 +393,6 @@ function fakeSweepParams(client: FakeScanDynamo): SweepParams {
393
393
  ChannelMeta: 'CM',
394
394
  ChannelMembers: 'CMem',
395
395
  Nicks: 'N',
396
- Accounts: 'A',
397
396
  },
398
397
  now: 10_000,
399
398
  staleAfterMs: 1_000,
@@ -13,13 +13,12 @@ import {
13
13
  } from '../src/tables.js';
14
14
 
15
15
  describe('TABLE_NAMES', () => {
16
- it('contains exactly the six canonical logical names', () => {
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);
@@ -364,7 +364,6 @@ function fakeRuntime(client: FakeDynamo): AwsRuntime {
364
364
  ChannelMeta: 'CM',
365
365
  ChannelMembers: 'CMem',
366
366
  Nicks: 'N',
367
- Accounts: 'A',
368
367
  },
369
368
  connId: 'probe',
370
369
  handlers,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.9.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
- if (dto === null) return;
185
- const state = deserialize(dto);
186
- if (!state.userModes.wallops) return;
187
- await (stub as unknown as ConnectionRpc).deliver(lines);
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 dto === null ? null : deserialize(dto);
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
- if (dto !== null) {
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
- function deserializeChannel(record: PersistedChannelState): ChannelState {
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