serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Marshal / unmarshal helpers between irc-core state objects and the
3
+ * DynamoDB NativeAttributeMap shapes (`@aws-sdk/util-dynamodb`).
4
+ *
5
+ * Why custom (not `marshall`/`unmarshall` alone): irc-core uses `Set`
6
+ * and `Map` for `caps`, `joinedChannels`, channel `members`, `banMasks`,
7
+ * etc. The AWS SDK's default marshallers turn `Set` into a DynamoDB
8
+ * `SS`/`NS`/`BS` typed value and `Map` into an `M`-typed nested attribute
9
+ * — both of which are awkward to query. We expand Sets to arrays and
10
+ * Maps to arrays of `[key, value]` pairs so every column is a flat
11
+ * scalar/list whose shape matches the PLAN §4 access patterns.
12
+ *
13
+ * Lifecycle:
14
+ * - `marshal*` — irc-core value → DynamoDB item (write path).
15
+ * - `unmarshal*` — DynamoDB item → irc-core value (read path).
16
+ *
17
+ * Round-trip invariant: `unmarshal*(marshal*(x))` is structurally equal
18
+ * to `x` for every value `x`. Tested exhaustively against the
19
+ * irc-core state generators.
20
+ */
21
+
22
+ import type {
23
+ ChanName,
24
+ ChannelModes,
25
+ ChannelState,
26
+ ChannelTopic,
27
+ ConnId,
28
+ ConnectionState,
29
+ Nick,
30
+ RegistrationState,
31
+ RosterEntry,
32
+ UserModes,
33
+ } from '@serverless-ircd/irc-core';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Connections
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Current Connections schema version. Bump when the shape changes. */
40
+ export const CONNECTION_VERSION = 1;
41
+
42
+ /**
43
+ * Persists a {@link ConnectionState} row. The `version` field supports
44
+ * forward migrations on read (mirrors the CF adapter's `PersistedConnectionState`).
45
+ *
46
+ * Storage-shape notes:
47
+ * - `joinedChannels` is stored as a DynamoDB **String Set** (`SS`) so
48
+ * the membership transaction can atomically `ADD`/`DELETE` a channel
49
+ * (DynamoDB set ops). The attribute is OMITTED when empty because
50
+ * DynamoDB rejects empty sets — the transaction path creates it on
51
+ * the first JOIN. `caps` stays an array: nothing needs to mutate a
52
+ * single cap atomically.
53
+ */
54
+ export interface MarshalledConnection {
55
+ connectionId: ConnId;
56
+ registration: RegistrationState;
57
+ capNegotiating: boolean;
58
+ caps: string[];
59
+ /**
60
+ * Present (as a JS `Set`, auto-marshalled to `SS`) only when the
61
+ * connection has joined at least one channel. Absent otherwise —
62
+ * DynamoDB forbids empty sets, so we omit rather than store `SS: []`.
63
+ */
64
+ joinedChannels?: Set<ChanName>;
65
+ userModes: UserModes;
66
+ lastSeen: number;
67
+ connectedSince: number;
68
+ idleSince: number;
69
+ version: number;
70
+ nick?: Nick;
71
+ user?: string;
72
+ host?: string;
73
+ realname?: string;
74
+ passAttempt?: string;
75
+ account?: string;
76
+ away?: string;
77
+ saslMech?: string;
78
+ saslBuffer?: string;
79
+ }
80
+
81
+ /**
82
+ * Marshals a {@link ConnectionState} into a plain JS object ready for
83
+ * `PutCommand` / `UpdateCommand`. `idleSince` is the TTL attribute and
84
+ * is set to the supplied epoch ms (callers pass `Date.now()`).
85
+ *
86
+ * `joinedChannels` is emitted as a native `Set` only when non-empty;
87
+ * the AWS SDK marshalls it to a DynamoDB `SS`, enabling atomic
88
+ * `ADD`/`DELETE` from the membership transaction without read-modify-write.
89
+ */
90
+ export function marshalConnection(state: ConnectionState, idleSince: number): MarshalledConnection {
91
+ const out: MarshalledConnection = {
92
+ connectionId: state.id,
93
+ registration: state.registration,
94
+ capNegotiating: state.capNegotiating,
95
+ caps: [...state.caps],
96
+ userModes: { ...state.userModes },
97
+ lastSeen: state.lastSeen,
98
+ connectedSince: state.connectedSince,
99
+ idleSince,
100
+ version: CONNECTION_VERSION,
101
+ };
102
+ if (state.joinedChannels.size > 0) {
103
+ // Copy so later mutations to `state` don't alias the stored value.
104
+ out.joinedChannels = new Set<ChanName>(state.joinedChannels);
105
+ }
106
+ if (state.nick !== undefined) out.nick = state.nick;
107
+ if (state.user !== undefined) out.user = state.user;
108
+ if (state.host !== undefined) out.host = state.host;
109
+ if (state.realname !== undefined) out.realname = state.realname;
110
+ if (state.passAttempt !== undefined) out.passAttempt = state.passAttempt;
111
+ if (state.account !== undefined) out.account = state.account;
112
+ if (state.away !== undefined) out.away = state.away;
113
+ if (state.saslMech !== undefined) out.saslMech = state.saslMech;
114
+ if (state.saslBuffer !== undefined) out.saslBuffer = state.saslBuffer;
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Materialises a {@link ConnectionState} from a stored row. Rehydrates
120
+ * `caps` and `joinedChannels` into Sets. Throws on unrecognised
121
+ * `version` values (forward migrations live in a future ticket).
122
+ */
123
+ export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
124
+ if (row.version > CONNECTION_VERSION) {
125
+ throw new Error(
126
+ `Connections row version ${row.version} is newer than supported ${CONNECTION_VERSION}`,
127
+ );
128
+ }
129
+ const state: ConnectionState = {
130
+ id: row.connectionId,
131
+ registration: row.registration,
132
+ capNegotiating: row.capNegotiating,
133
+ caps: new Set<string>(row.caps),
134
+ // `joinedChannels` is absent on rows that never joined a channel
135
+ // (DynamoDB rejects empty sets); default to an empty Set. The SDK
136
+ // unmarshalls `SS` back to a native Set, which the Set constructor
137
+ // copies; arrays (older rows) also iterate fine.
138
+ joinedChannels: new Set<ChanName>(row.joinedChannels ?? []),
139
+ userModes: { ...row.userModes },
140
+ lastSeen: row.lastSeen,
141
+ connectedSince: row.connectedSince,
142
+ };
143
+ if (row.nick !== undefined) state.nick = row.nick;
144
+ if (row.user !== undefined) state.user = row.user;
145
+ if (row.host !== undefined) state.host = row.host;
146
+ if (row.realname !== undefined) state.realname = row.realname;
147
+ if (row.passAttempt !== undefined) state.passAttempt = row.passAttempt;
148
+ if (row.account !== undefined) state.account = row.account;
149
+ if (row.away !== undefined) state.away = row.away;
150
+ if (row.saslMech !== undefined) state.saslMech = row.saslMech;
151
+ if (row.saslBuffer !== undefined) state.saslBuffer = row.saslBuffer;
152
+ return state;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Nicks
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /** Stored shape for the `Nicks` table. */
160
+ export interface MarshalledNick {
161
+ nickLower: string;
162
+ nick: Nick;
163
+ connectionId: ConnId;
164
+ reservedAt: number;
165
+ }
166
+
167
+ /** Marshals a nick registration into the stored row shape. */
168
+ export function marshalNick(nick: Nick, connectionId: ConnId, reservedAt: number): MarshalledNick {
169
+ return {
170
+ nickLower: nick.toLowerCase(),
171
+ nick,
172
+ connectionId,
173
+ reservedAt,
174
+ };
175
+ }
176
+
177
+ /** Materialises the canonical (nick → connectionId) lookup from a row. */
178
+ export function unmarshalNick(row: MarshalledNick): { nick: Nick; connectionId: ConnId } {
179
+ return { nick: row.nick, connectionId: row.connectionId };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // ChannelMeta
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** Stored shape for the `ChannelMeta` table (modes + topic, no members). */
187
+ export interface MarshalledChannelMeta {
188
+ channelName: string;
189
+ displayName: ChanName;
190
+ modes: ChannelModes;
191
+ banMasks: string[];
192
+ pendingInvites: string[];
193
+ createdAt: number;
194
+ topicText?: string;
195
+ topicSetter?: string;
196
+ topicSetAt?: number;
197
+ }
198
+
199
+ /** Marshals a channel's metadata into the stored row shape. */
200
+ export function marshalChannelMeta(chan: ChannelState): MarshalledChannelMeta {
201
+ const out: MarshalledChannelMeta = {
202
+ channelName: chan.nameLower,
203
+ displayName: chan.name,
204
+ modes: { ...chan.modes },
205
+ banMasks: [...chan.banMasks],
206
+ pendingInvites: [...chan.pendingInvites],
207
+ createdAt: chan.createdAt,
208
+ };
209
+ if (chan.topic !== undefined) {
210
+ out.topicText = chan.topic.text;
211
+ out.topicSetter = chan.topic.setter;
212
+ out.topicSetAt = chan.topic.setAt;
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * Materialises a {@link ChannelState} from the meta row + the (separately
219
+ * fetched) member list. The caller is expected to populate `members`
220
+ * afterwards — meta and members live in different tables so they're fetched
221
+ * in parallel by the runtime.
222
+ */
223
+ export function unmarshalChannelMeta(
224
+ row: MarshalledChannelMeta,
225
+ members: ReadonlyMap<ConnId, RosterEntry>,
226
+ ): ChannelState {
227
+ let topic: ChannelTopic | undefined;
228
+ if (
229
+ row.topicText !== undefined &&
230
+ row.topicSetter !== undefined &&
231
+ row.topicSetAt !== undefined
232
+ ) {
233
+ topic = { text: row.topicText, setter: row.topicSetter, setAt: row.topicSetAt };
234
+ }
235
+ const chan: ChannelState = {
236
+ name: row.displayName,
237
+ nameLower: row.channelName,
238
+ modes: { ...row.modes },
239
+ banMasks: new Set<string>(row.banMasks),
240
+ members: new Map<ConnId, RosterEntry>(members),
241
+ pendingInvites: new Set<Nick>(row.pendingInvites),
242
+ createdAt: row.createdAt,
243
+ };
244
+ if (topic !== undefined) chan.topic = topic;
245
+ return chan;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // ChannelMembers
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /** Stored shape for the `ChannelMembers` table — one row per (chan, conn). */
253
+ export interface MarshalledChannelMember {
254
+ channelName: string;
255
+ connectionId: ConnId;
256
+ nick: Nick;
257
+ op: boolean;
258
+ voice: boolean;
259
+ }
260
+
261
+ /** Marshals a single roster entry into the stored row shape. */
262
+ export function marshalChannelMember(
263
+ channelName: string,
264
+ entry: RosterEntry,
265
+ ): MarshalledChannelMember {
266
+ return {
267
+ channelName,
268
+ connectionId: entry.conn,
269
+ nick: entry.nick,
270
+ op: entry.op,
271
+ voice: entry.voice,
272
+ };
273
+ }
274
+
275
+ /** Materialises a {@link RosterEntry} from a stored member row. */
276
+ export function unmarshalChannelMember(row: MarshalledChannelMember): RosterEntry {
277
+ return {
278
+ conn: row.connectionId,
279
+ nick: row.nick,
280
+ op: row.op,
281
+ voice: row.voice,
282
+ };
283
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Runtime-safe DynamoDB table metadata (PLAN §4).
3
+ *
4
+ * The CDK-coupled `TABLE_DEFS` (TableProps with `AttributeType`/`BillingMode`
5
+ * enums) lives in `./cdk-table-defs.ts` so the Lambda handler bundle does
6
+ * not have to ship `aws-cdk-lib`. The runtime only needs:
7
+ *
8
+ * - the *names* of the logical tables (so env-var→TablesConfig lookup works),
9
+ * - the *names* of the key columns (so DynamoDB DocumentClient commands
10
+ * reference them without stringly-typed typos).
11
+ *
12
+ * Both files share {@link TableName} as the single source of truth for the
13
+ * canonical logical names (`Connections`, `ChannelMeta`, …).
14
+ */
15
+
16
+ /** Logical table names — also used as CDK construct ids. */
17
+ export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Accounts';
18
+
19
+ /** Logical table names in stable order. */
20
+ export const TABLE_NAMES: readonly TableName[] = [
21
+ 'Connections',
22
+ 'ChannelMeta',
23
+ 'ChannelMembers',
24
+ 'Nicks',
25
+ 'Accounts',
26
+ ];
27
+
28
+ /** Key column names used throughout the runtime (avoids stringly-typed typos). */
29
+ export const TABLE_KEYS = {
30
+ Connections: { pk: 'connectionId' },
31
+ ChannelMeta: { pk: 'channelName' },
32
+ ChannelMembers: { pk: 'channelName', sk: 'connectionId' },
33
+ Nicks: { pk: 'nickLower' },
34
+ Accounts: { pk: 'account' },
35
+ } as const;
36
+
37
+ /**
38
+ * Maps logical names to physical table names. Production uses the bare
39
+ * names; tests override with a unique prefix per harness to isolate
40
+ * parallel runs against a shared DynamoDB Local instance.
41
+ */
42
+ export type TablesConfig = Record<TableName, string>;
43
+
44
+ /** Builds a {@link TablesConfig} from the supplied physical table names. */
45
+ export function tablesConfigFromNames(names: {
46
+ Connections: string;
47
+ ChannelMeta: string;
48
+ ChannelMembers: string;
49
+ Nicks: string;
50
+ Accounts: string;
51
+ }): TablesConfig {
52
+ return { ...names };
53
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * DynamoDB-backed integration tests for the `Accounts` table path.
3
+ * Exercises the real DynamoDB round-trip that pure unit
4
+ * tests cannot: `putAccountCredential` writes a hashed row,
5
+ * `loadDynamoAccountStore` scans it back, and `resolveAccountStore`
6
+ * applies the table-then-config precedence.
7
+ *
8
+ * Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
9
+ * `handlers.test.ts`).
10
+ */
11
+
12
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
+ import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import {
16
+ loadDynamoAccountStore,
17
+ putAccountCredential,
18
+ resolveAccountStore,
19
+ } from '../src/account-store.js';
20
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
21
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
22
+
23
+ const available = process.env.DDB_AVAILABLE === '1';
24
+ const endpoint = process.env.DYNAMO_ENDPOINT;
25
+
26
+ interface Fx {
27
+ tableName: string;
28
+ client: ReturnType<typeof createDynamoDocumentClient>;
29
+ cleanup: () => Promise<void>;
30
+ }
31
+
32
+ let fx: Fx | null = null;
33
+
34
+ async function makeFx(): Promise<Fx> {
35
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
36
+ const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
37
+ const client = createDynamoDocumentClient({ endpoint });
38
+ const props = TABLE_DEFS.Accounts;
39
+ const pkName = props.partitionKey?.name;
40
+ if (pkName === undefined) throw new Error('Accounts table missing partitionKey');
41
+ await client.send(
42
+ new CreateTableCommand({
43
+ TableName: tableName,
44
+ AttributeDefinitions: [{ AttributeName: pkName, AttributeType: 'S' }],
45
+ KeySchema: [{ AttributeName: pkName, KeyType: 'HASH' }],
46
+ BillingMode: 'PAY_PER_REQUEST',
47
+ }),
48
+ );
49
+ return {
50
+ tableName,
51
+ client,
52
+ cleanup: async () => {
53
+ try {
54
+ await client.send(new DeleteTableCommand({ TableName: tableName }));
55
+ } catch {
56
+ // Idempotent.
57
+ }
58
+ },
59
+ };
60
+ }
61
+
62
+ describe.skipIf(!available)('Accounts table round-trip', () => {
63
+ beforeEach(async () => {
64
+ fx = await makeFx();
65
+ });
66
+ afterEach(async () => {
67
+ if (fx) await fx.cleanup();
68
+ fx = null;
69
+ });
70
+
71
+ it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
72
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 's3cret');
73
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
74
+ expect(store).toBeDefined();
75
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' })).toEqual(
76
+ {
77
+ ok: true,
78
+ account: 'alice',
79
+ },
80
+ );
81
+ });
82
+
83
+ it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
84
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
85
+ expect(store).toBeUndefined();
86
+ });
87
+
88
+ it('putAccountCredential writes multiple distinct accounts', async () => {
89
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'a1');
90
+ await putAccountCredential(fx!.client, fx!.tableName, 'bob', 'b2');
91
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
92
+ expect(store).toBeDefined();
93
+ expect(store!.size).toBe(2);
94
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
95
+ true,
96
+ );
97
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(true);
98
+ });
99
+
100
+ it('putAccountCredential overwrites an existing account (re-seed)', async () => {
101
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'old');
102
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'new');
103
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
104
+ expect(store).toBeDefined();
105
+ expect(store!.size).toBe(1);
106
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
107
+ false,
108
+ );
109
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
110
+ true,
111
+ );
112
+ });
113
+
114
+ it('rejects a wrong password after round-trip', async () => {
115
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'right');
116
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
117
+ expect(store).toBeDefined();
118
+ expect(
119
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok,
120
+ ).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe.skipIf(!available)('resolveAccountStore precedence', () => {
125
+ beforeEach(async () => {
126
+ fx = await makeFx();
127
+ });
128
+ afterEach(async () => {
129
+ if (fx) await fx.cleanup();
130
+ fx = null;
131
+ });
132
+
133
+ it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
134
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'table-secret');
135
+ const cfgWithSasl = {
136
+ ...DEFAULT_SERVER_CONFIG,
137
+ saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
138
+ };
139
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
140
+ expect(store).toBeDefined();
141
+ // Table account verifies; config account does NOT (table is authoritative).
142
+ expect(
143
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
144
+ ).toBe(true);
145
+ expect(
146
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('falls back to the config-seeded store when the table is empty', async () => {
151
+ const cfgWithSasl = {
152
+ ...DEFAULT_SERVER_CONFIG,
153
+ saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
154
+ };
155
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
156
+ expect(store).toBeDefined();
157
+ expect(
158
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
159
+ ).toBe(true);
160
+ });
161
+
162
+ it('returns undefined when the table is empty and no config accounts are set', async () => {
163
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, DEFAULT_SERVER_CONFIG);
164
+ expect(store).toBeUndefined();
165
+ });
166
+
167
+ it('returns undefined when the table is empty and serverConfig is undefined', async () => {
168
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, undefined);
169
+ expect(store).toBeUndefined();
170
+ });
171
+ });