serverless-ircd 0.5.0 → 0.6.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 (167) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * IRCv3 `labeled-response` — correlate a request with its replies.
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/labeled-response
5
+ *
6
+ * A client tags an inbound message with the `+label=<id>` client tag; the
7
+ * server then wraps every reply caused by that message (addressed back to
8
+ * the requester) in a single `BATCH +id labeled-response … BATCH -id`
9
+ * frame. If the request produces no reply for the requester, the server
10
+ * emits an empty batch so the client can still correlate the label with a
11
+ * definitive "no replies" answer.
12
+ *
13
+ * Unlike the generic {@link wrapBatch} helper — which elides empty batches
14
+ * because the IRCv3 `batch` spec forbids them — {@link wrapLabeledResponse}
15
+ * ALWAYS emits the start/end markers. The empty-batch case is the whole
16
+ * point of labeled-response: it is the correlation signal when there is
17
+ * nothing else to send.
18
+ *
19
+ * This module is pure data transformation. The actor layer:
20
+ * 1. extracts the label from the parsed message tags,
21
+ * 2. plumbs it onto {@link Ctx.label} so every reducer's emitted effects
22
+ * are uniformly attributable to the labeled request, and
23
+ * 3. runs {@link applyLabelToEffects} over the reducer's `Effect[]` to
24
+ * gather every `Send`-to-self and wrap them (or emit an empty batch).
25
+ *
26
+ * Only `Send` effects addressed to the originating connection are wrapped.
27
+ * Broadcasts / `SendToNick` / `BroadcastWallops` reach OTHER connections
28
+ * and are left in place; the label correlation applies solely to the
29
+ * requester's own reply stream.
30
+ */
31
+
32
+ import { Effect } from '../effects.js';
33
+ import type { Effect as EffectType, RawLine } from '../effects.js';
34
+ import type { ConnId } from '../state/connection.js';
35
+
36
+ /** Wire name of the `labeled-response` capability. */
37
+ export const LABELED_RESPONSE_CAP_NAME = 'labeled-response';
38
+
39
+ /** Wire name of the `+label` client tag carried on the inbound message. */
40
+ export const LABEL_TAG = '+label';
41
+
42
+ /** Batch type token used for every labeled-response frame. */
43
+ const LABELED_RESPONSE_BATCH_TYPE = 'labeled-response';
44
+
45
+ /**
46
+ * Reads the `+label` client tag from a parsed message's tag map.
47
+ *
48
+ * Returns `undefined` when the tag is absent OR present-but-empty, so callers
49
+ * can treat a missing label and a bare `+label` (no value) identically: both
50
+ * mean "this request is unlabeled".
51
+ */
52
+ export function extractLabel(tags: Readonly<Record<string, string>>): string | undefined {
53
+ const value = tags[LABEL_TAG];
54
+ if (value === undefined || value.length === 0) return undefined;
55
+ return value;
56
+ }
57
+
58
+ /**
59
+ * Wraps `lines` in IRCv3 `BATCH +label labeled-response … BATCH -label`
60
+ * framing. ALWAYS emits the start and end markers — even when `lines` is
61
+ * empty — because the labeled-response spec requires an empty batch when a
62
+ * labeled request produces no reply (it is the correlation signal).
63
+ */
64
+ export function wrapLabeledResponse(label: string, lines: readonly RawLine[]): RawLine[] {
65
+ const start: RawLine = { text: `BATCH +${label} ${LABELED_RESPONSE_BATCH_TYPE}` };
66
+ const end: RawLine = { text: `BATCH -${label}` };
67
+ return [start, ...lines, end];
68
+ }
69
+
70
+ /**
71
+ * Options for {@link applyLabelToEffects}.
72
+ *
73
+ * `label` is `string | undefined` (NOT an optional field) so the helper can
74
+ * be a clean no-op under `exactOptionalPropertyTypes` when the inbound
75
+ * request carried no label.
76
+ */
77
+ export interface LabelApplyOptions {
78
+ /** The label extracted from the inbound `+label` tag, or `undefined`. */
79
+ readonly label: string | undefined;
80
+ /** The originating connection id (replies wrapped are addressed here). */
81
+ readonly connId: ConnId;
82
+ }
83
+
84
+ /**
85
+ * Gathers every `Send` effect addressed to `connId` and replaces them with a
86
+ * single `Send` whose lines are wrapped in one `labeled-response` batch.
87
+ *
88
+ * Behaviour:
89
+ * - When `label` is `undefined` the effect list is returned unchanged
90
+ * (copy) — the request was not labeled.
91
+ * - All `Send`-to-`connId` effects, wherever they appear, are accumulated
92
+ * in original order into one batch.
93
+ * - The wrapped batch is placed at the position of the FIRST collected
94
+ * `Send`-to-self, so the reducer's intended ordering relative to other
95
+ * effects (broadcasts, etc.) is preserved.
96
+ * - When NO `Send`-to-self exists, an empty labeled batch is appended at
97
+ * the end — the requester still observes the label correlation.
98
+ * - Every other effect (Broadcast, SendToNick, Disconnect, …) is left in
99
+ * place and in order; only the requester's own reply stream is wrapped.
100
+ */
101
+ export function applyLabelToEffects(
102
+ effects: readonly EffectType[],
103
+ opts: LabelApplyOptions,
104
+ ): EffectType[] {
105
+ const { label, connId } = opts;
106
+ if (label === undefined) return [...effects];
107
+
108
+ const selfLines: RawLine[] = [];
109
+ let firstSelfIdx = -1;
110
+ for (const [i, effect] of effects.entries()) {
111
+ if (effect.tag === 'Send' && effect.to === connId) {
112
+ if (firstSelfIdx === -1) firstSelfIdx = i;
113
+ selfLines.push(...effect.lines);
114
+ }
115
+ }
116
+
117
+ const batchEffect: EffectType = Effect.send(connId, wrapLabeledResponse(label, selfLines));
118
+
119
+ // No self-send to anchor on: append the empty labeled batch at the end.
120
+ if (firstSelfIdx === -1) {
121
+ return [...effects, batchEffect];
122
+ }
123
+
124
+ const out: EffectType[] = [];
125
+ for (const [i, effect] of effects.entries()) {
126
+ // Emit the single wrapped batch where the first self-send lived.
127
+ if (i === firstSelfIdx) out.push(batchEffect);
128
+ // Drop every original self-send (they are now inside the batch).
129
+ if (effect.tag === 'Send' && effect.to === connId) continue;
130
+ out.push(effect);
131
+ }
132
+ return out;
133
+ }
@@ -53,6 +53,15 @@ export const Numerics = {
53
53
  RPL_UNAWAY: 305,
54
54
  RPL_NOWAWAY: 306,
55
55
 
56
+ // IRCv3 MONITOR (modern ISON replacement). The watchlist lives on
57
+ // {@link ConnectionState.monitorList}; transitions push 730/731
58
+ // asynchronously via the actor's nick-registry hooks.
59
+ RPL_MONONLINE: 730,
60
+ RPL_MONOFFLINE: 731,
61
+ RPL_MONLIST: 732,
62
+ RPL_ENDOFMONLIST: 733,
63
+ ERR_MONLISTFULL: 734,
64
+
56
65
  // Version
57
66
  RPL_VERSION: 351,
58
67
 
@@ -64,6 +73,12 @@ export const Numerics = {
64
73
  RPL_ENDOFWHOIS: 318,
65
74
  RPL_WHOISCHANNELS: 319,
66
75
  RPL_WHOISACCOUNT: 330,
76
+ /**
77
+ * `276 RPL_WHOISSECURE` — target is connected over TLS (user mode `S`).
78
+ * Surfaced between the oper/account lines and the idle line so clients
79
+ * render it as part of the secure-connection WHOIS block.
80
+ */
81
+ RPL_WHOISSECURE: 276,
67
82
  RPL_WHOWASUSER: 314,
68
83
 
69
84
  // LIST
@@ -126,6 +126,14 @@ export function enforceLineLimit(
126
126
  * Server-defined tags (no `+` prefix) are left in place — each is gated by
127
127
  * its own capability (server-time, account-tag, …) applied elsewhere.
128
128
  *
129
+ * Spec exception: IRCv3 `draft/typing` carves out a whitelist for the
130
+ * `+draft/typing` client tag. A recipient that announced `draft/typing`
131
+ * (but NOT `message-tags`) still receives `+draft/typing` so typing
132
+ * indicators work without the broader message-tags cap. `draft/read-marker`
133
+ * carves out the same exception for its `+draft/read-marker` tag so a
134
+ * read-marker update reaches the user's other connections. Every other
135
+ * client tag remains stripped for such recipients.
136
+ *
129
137
  * When filtering removes every tag, the leading `@... ` section is dropped
130
138
  * entirely so legacy clients never observe a bare tag prefix.
131
139
  *
@@ -142,10 +150,17 @@ export function filterClientTags(line: RawLine, caps: ReadonlySet<string>): RawL
142
150
  const rest = line.text.slice(spaceIdx + 1);
143
151
  const entries = tagSection.split(';');
144
152
 
153
+ const typingWhitelisted = caps.has('draft/typing');
154
+ const readMarkerWhitelisted = caps.has('draft/read-marker');
145
155
  const hasClientTag = entries.some((entry) => clientTagKey(entry).startsWith('+'));
146
156
  if (!hasClientTag) return line;
147
157
 
148
- const kept = entries.filter((entry) => !clientTagKey(entry).startsWith('+'));
158
+ const kept = entries.filter((entry) => {
159
+ const key = clientTagKey(entry);
160
+ if (!key.startsWith('+')) return true;
161
+ if (typingWhitelisted && key === '+draft/typing') return true;
162
+ return readMarkerWhitelisted && key === '+draft/read-marker';
163
+ });
149
164
  if (kept.length === 0) return { text: rest };
150
165
  return { text: `@${kept.join(';')} ${rest}` };
151
166
  }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * IRCv3 `standard-replies` — `FAIL` / `WARN` / `NOTE` dispatch helper.
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/standard-replies-3.0
5
+ *
6
+ * Modern IRC clients that negotiated the `standard-replies` cap accept the
7
+ * structured reply form
8
+ *
9
+ * :<server> FAIL <command> <code> [<context>...] :<description>
10
+ * :<server> WARN <command> <code> [<context>...] :<description>
11
+ * :<server> NOTE <command> <code> [<context>...] :<description>
12
+ *
13
+ * in place of the legacy `:server NNN nick ... :trailing` numerics for a
14
+ * curated subset of errors. Numeric-only replies (MOTD lines, NAMES,
15
+ * welcome block, …) stay on the numeric form — only the curated error
16
+ * numerics are translated.
17
+ *
18
+ * This module is the data-driven mapping plus a single pure dispatch
19
+ * helper ({@link toStandardReply}) that the dispatch / outbound layer
20
+ * applies per recipient. Translating at the outbound layer (rather than
21
+ * scattering `if (caps.has('standard-replies'))` through every reducer)
22
+ * keeps the reducer code cap-agnostic, matching the model already used
23
+ * for `server-time` and `message-tags`.
24
+ *
25
+ * Reducers keep emitting the canonical numeric line; the dispatch layer
26
+ * runs this helper to translate the curated numerics for cap-enabled
27
+ * recipients and passes everything else through verbatim.
28
+ */
29
+
30
+ import type { RawLine } from '../effects.js';
31
+ import { Numerics } from './numerics.js';
32
+
33
+ /** One of the IRCv3 standard-reply verbs. */
34
+ export type ReplyKind = 'FAIL' | 'WARN' | 'NOTE';
35
+
36
+ /**
37
+ * One row of the {@link numericToStandardReply} table, as a discriminated
38
+ * union over where the COMMAND token comes from on the wire.
39
+ *
40
+ * - When {@link CommandFromMiddle.commandFromMiddle} is `true`, the
41
+ * numeric's middle parameter IS the failed command name (true for
42
+ * `461 ERR_NEEDMOREPARAMS`, whose middle is the command the client
43
+ * undersupplied).
44
+ * - When {@link CommandFixed.commandFromMiddle} is `false`, a fixed
45
+ * {@link CommandFixed.command} is emitted (true for `432`/`433`,
46
+ * always `NICK`, and for SASL failures, always `AUTHENTICATE`).
47
+ *
48
+ * The discriminated union lets `pickCommand` return `mapping.command`
49
+ * directly in the fixed branch with no defensive `?? null` — the type
50
+ * system proves `command` is always set there.
51
+ */
52
+ export type StandardReplyMapping = CommandFromMiddle | CommandFixed;
53
+
54
+ /** A row whose COMMAND token comes from the numeric's middle parameter. */
55
+ export interface CommandFromMiddle {
56
+ /** Discriminant: true → read COMMAND from `parsed.middle[0]`. */
57
+ commandFromMiddle: true;
58
+ /** Which of `FAIL`/`WARN`/`NOTE` to emit. */
59
+ kind: ReplyKind;
60
+ /** Stable wire code (`INVALID_PARAMS`, …). */
61
+ code: string;
62
+ /** Always false here — the middle is consumed as the command, not context. */
63
+ contextFromMiddle: false;
64
+ }
65
+
66
+ /** A row whose COMMAND token is a fixed string. */
67
+ export interface CommandFixed {
68
+ /** Discriminant: false → read COMMAND from {@link command}. */
69
+ commandFromMiddle: false;
70
+ /** Which of `FAIL`/`WARN`/`NOTE` to emit. */
71
+ kind: ReplyKind;
72
+ /** Stable wire code (`NO_SUCH_CHANNEL`, `INVALID_NICK`, …). */
73
+ code: string;
74
+ /** Fixed command token emitted after the FAIL/WARN/NOTE verb. */
75
+ command: string;
76
+ /**
77
+ * True iff the numeric's middle parameter carries a context token
78
+ * (the bad nick, the bad channel) that should follow the code.
79
+ */
80
+ contextFromMiddle: boolean;
81
+ }
82
+
83
+ /**
84
+ * The curated numeric → standard-reply mapping.
85
+ *
86
+ * Only a deliberately small subset of error numerics is translated —
87
+ * the ones that carry enough context for the structured form to be a
88
+ * strict improvement over the bare numeric. Numeric-only families
89
+ * (MOTD, NAMES, the welcome block, mode replies, …) stay on the
90
+ * numeric form per the spec.
91
+ */
92
+ export const numericToStandardReply: ReadonlyMap<number, StandardReplyMapping> = new Map<
93
+ number,
94
+ StandardReplyMapping
95
+ >([
96
+ [
97
+ Numerics.ERR_NEEDMOREPARAMS,
98
+ {
99
+ kind: 'FAIL',
100
+ code: 'INVALID_PARAMS',
101
+ commandFromMiddle: true,
102
+ contextFromMiddle: false,
103
+ },
104
+ ],
105
+ [
106
+ Numerics.ERR_NOSUCHCHANNEL,
107
+ {
108
+ kind: 'FAIL',
109
+ code: 'NO_SUCH_CHANNEL',
110
+ commandFromMiddle: false,
111
+ command: 'JOIN',
112
+ contextFromMiddle: true,
113
+ },
114
+ ],
115
+ [
116
+ Numerics.ERR_ERRONEUSNICKNAME,
117
+ {
118
+ kind: 'FAIL',
119
+ code: 'INVALID_NICK',
120
+ commandFromMiddle: false,
121
+ command: 'NICK',
122
+ contextFromMiddle: true,
123
+ },
124
+ ],
125
+ [
126
+ Numerics.ERR_NICKNAMEINUSE,
127
+ {
128
+ kind: 'FAIL',
129
+ code: 'NICKNAME_IN_USE',
130
+ commandFromMiddle: false,
131
+ command: 'NICK',
132
+ contextFromMiddle: true,
133
+ },
134
+ ],
135
+ [
136
+ Numerics.ERR_SASLFAIL,
137
+ {
138
+ kind: 'FAIL',
139
+ code: 'SASL_FAILED',
140
+ commandFromMiddle: false,
141
+ command: 'AUTHENTICATE',
142
+ contextFromMiddle: false,
143
+ },
144
+ ],
145
+ ]);
146
+
147
+ /**
148
+ * Translates a single outbound {@link RawLine} for a recipient whose
149
+ * negotiated caps are `caps`.
150
+ *
151
+ * - If the recipient lacks the `standard-replies` cap, the line is
152
+ * returned unchanged (the legacy numeric still ships).
153
+ * - If the line is not a `:server NNN …` numeric, it is returned
154
+ * unchanged (PRIVMSG, BATCH, FAIL/WARN/NOTE already emitted by
155
+ * reducers, etc.).
156
+ * - If the numeric is not in {@link numericToStandardReply}, the line
157
+ * is returned unchanged.
158
+ * - Otherwise the line is rewritten into the structured
159
+ * `FAIL`/`WARN`/`NOTE` form using the table.
160
+ *
161
+ * The input is never mutated; the original object is returned whenever
162
+ * no translation occurred so callers can cheaply detect "no change".
163
+ */
164
+ export function toStandardReply(line: RawLine, caps: ReadonlySet<string>): RawLine {
165
+ if (!caps.has('standard-replies')) return line;
166
+ if (!line.text.startsWith(':')) return line;
167
+
168
+ const parsed = parseNumericLine(line.text);
169
+ if (parsed === null) return line;
170
+
171
+ const mapping = numericToStandardReply.get(parsed.code);
172
+ if (mapping === undefined) return line;
173
+
174
+ const command = pickCommand(parsed, mapping);
175
+ if (command === null) return line;
176
+
177
+ // The structured form requires a `:description` token. A curated numeric
178
+ // missing its trailing parameter (malformed input that never ships from a
179
+ // reducer) is left on the bare numeric so we never emit an empty FAIL.
180
+ if (parsed.trailing === undefined) return line;
181
+
182
+ const context = pickContext(parsed, mapping);
183
+ const trailing = parsed.trailing;
184
+
185
+ const head = `:${parsed.server} ${mapping.kind} ${command} ${mapping.code}`;
186
+ const withContext = context !== null ? `${head} ${context}` : head;
187
+ return { text: `${withContext} :${trailing}` };
188
+ }
189
+
190
+ /** Internal: the parsed shape of a `:server NNN nick [middle] [:trailing]` line. */
191
+ interface ParsedNumeric {
192
+ server: string;
193
+ code: number;
194
+ /** Every token between the nick and the trailing `:`-parameter. May be empty. */
195
+ middle: string[];
196
+ /** The trailing `:`-prefixed parameter, with the leading `:` stripped. */
197
+ trailing: string | undefined;
198
+ }
199
+
200
+ /**
201
+ * Parses a single `:server NNN ...` line into its components.
202
+ *
203
+ * Returns `null` for any shape this server does not emit for numerics:
204
+ * lines without a server prefix, lines whose second token is not a
205
+ * three-digit numeric, and lines with no nick token. The trailing
206
+ * `:`-parameter is optional (a handful of numerics — currently none in
207
+ * the curated set — ship without one).
208
+ */
209
+ function parseNumericLine(text: string): ParsedNumeric | null {
210
+ // Strip the leading `:server ` prefix.
211
+ const firstSpace = text.indexOf(' ');
212
+ if (firstSpace === -1) return null;
213
+ const server = text.slice(1, firstSpace);
214
+
215
+ const rest = text.slice(firstSpace + 1);
216
+ const secondSpace = rest.indexOf(' ');
217
+ if (secondSpace === -1) return null;
218
+ const codeTok = rest.slice(0, secondSpace);
219
+ if (!/^\d{3}$/.test(codeTok)) return null;
220
+ const code = Number.parseInt(codeTok, 10);
221
+
222
+ const afterCode = rest.slice(secondSpace + 1);
223
+
224
+ // The nick token is mandatory between the code and the optional trailing.
225
+ // Empty after-code or an `:`-prefixed after-code (the trailing param
226
+ // immediately following the code) is malformed for a numeric this server
227
+ // emits; bail so the caller passes the line through verbatim.
228
+ if (afterCode.length === 0) return null;
229
+ if (afterCode[0] === ':') return null;
230
+
231
+ // Split off the trailing `:`-parameter if present.
232
+ const trailingIdx = findTrailingColon(afterCode);
233
+ let head = afterCode;
234
+ let trailing: string | undefined;
235
+ if (trailingIdx !== -1) {
236
+ head = afterCode.slice(0, trailingIdx).trimEnd();
237
+ trailing = afterCode.slice(trailingIdx + 1);
238
+ }
239
+
240
+ // head starts with the nick and optional middle params.
241
+ const tokens = head.length === 0 ? [] : head.split(' ');
242
+ if (tokens.length === 0) return null;
243
+ // Drop the nick — it is not used in the structured form (the reply is
244
+ // routed to a specific recipient, not addressed by nick).
245
+ const middle = tokens.slice(1);
246
+
247
+ return { server, code, middle, trailing };
248
+ }
249
+
250
+ /**
251
+ * Returns the index of the ` :` that introduces the trailing parameter,
252
+ * or `-1` when the line has no trailing. The leading space is required
253
+ * so a `:` inside a middle token (e.g. a malformed channel name) is not
254
+ * misread.
255
+ */
256
+ function findTrailingColon(s: string): number {
257
+ for (let i = 0; i < s.length; i++) {
258
+ if (s[i] === ' ' && s[i + 1] === ':') return i + 1;
259
+ }
260
+ return -1;
261
+ }
262
+
263
+ /** Resolves the COMMAND token for the structured reply per the mapping. */
264
+ function pickCommand(parsed: ParsedNumeric, mapping: StandardReplyMapping): string | null {
265
+ if (mapping.commandFromMiddle) {
266
+ const cmd = parsed.middle[0];
267
+ return cmd === undefined ? null : cmd;
268
+ }
269
+ return mapping.command;
270
+ }
271
+
272
+ /** Resolves the optional CONTEXT token for the structured reply per the mapping. */
273
+ function pickContext(parsed: ParsedNumeric, mapping: StandardReplyMapping): string | null {
274
+ if (!mapping.contextFromMiddle) return null;
275
+ const ctx = parsed.middle[0];
276
+ return ctx ?? null;
277
+ }
@@ -20,13 +20,21 @@ export type RegistrationState = 'pre-registration' | 'registering' | 'registered
20
20
 
21
21
  /**
22
22
  * User modes applied to a connection. The IRC user-mode letters are
23
- * `i` (invisible), `o` (oper), `w` (wallops), `s` (server notices).
23
+ * `i` (invisible), `o` (oper), `w` (wallops), `s` (server notices),
24
+ * `S` (TLS connected — read-only, set by the transport at registration).
24
25
  */
25
26
  export interface UserModes {
26
27
  invisible: boolean;
27
28
  oper: boolean;
28
29
  wallops: boolean;
29
30
  serverNotices: boolean;
31
+ /**
32
+ * User mode `S` — the connection arrived over TLS (wss or irc+tls).
33
+ * Read-only: set once by the registration reducer from
34
+ * {@link ConnectionState.secure} and never mutated afterwards. Not
35
+ * settable via `MODE` (the parser rejects `+S`/`-S` with `501`).
36
+ */
37
+ tls: boolean;
30
38
  }
31
39
 
32
40
  /**
@@ -84,6 +92,15 @@ export interface ConnectionState {
84
92
  * `CAP END` arrives (per IRCv3 capability-negotiation spec).
85
93
  */
86
94
  capNegotiating: boolean;
95
+ /**
96
+ * Transport-level fact: true when the connection arrived over TLS
97
+ * (`wss://` WebSocket or `irc+tls://` raw TCP). Set once by the adapter
98
+ * at construction from the platform's transport metadata and read by the
99
+ * registration reducer to surface user mode `S` ({@link UserModes.tls}).
100
+ * Defaults to `false` so existing callers (plain ws test harnesses) keep
101
+ * the pre-TLS-mode behaviour.
102
+ */
103
+ secure: boolean;
87
104
  /** IRCv3 capabilities negotiated via `CAP`. */
88
105
  caps: Set<string>;
89
106
  /** Away reason; absent when not away. */
@@ -109,6 +126,18 @@ export interface ConnectionState {
109
126
  * PART/re-JOIN cycle with no new activity replays nothing.
110
127
  */
111
128
  lastReadMarkers?: Map<string, string>;
129
+ /**
130
+ * Per-connection MONITOR watchlist (IRCv3 `MONITOR`). Each entry is the
131
+ * case-folded (rfc1459) spelling of a watched nick; the display spelling
132
+ * is resolved at emission time via the nick registry. Lazily allocated
133
+ * on first `MONITOR +`; persisted verbatim so a reconnect can restore
134
+ * the watchlist without re-issuing every `MONITOR` line.
135
+ *
136
+ * Capped at the deployment's `MONITOR=<n>` ISUPPORT value (default 30);
137
+ * the {@link monitorReducer} enforces the cap and emits `734
138
+ * ERR_MONLISTFULL` for rejected nicks.
139
+ */
140
+ monitorList?: Set<string>;
112
141
  }
113
142
 
114
143
  /**
@@ -147,6 +176,7 @@ export function createConnection(opts: CreateConnectionOptions): ConnectionState
147
176
  id: opts.id,
148
177
  registration: 'pre-registration',
149
178
  capNegotiating: false,
179
+ secure: false,
150
180
  caps: new Set<string>(),
151
181
  joinedChannels: new Set<ChanName>(),
152
182
  userModes: {
@@ -154,6 +184,7 @@ export function createConnection(opts: CreateConnectionOptions): ConnectionState
154
184
  oper: false,
155
185
  wallops: false,
156
186
  serverNotices: false,
187
+ tls: false,
157
188
  },
158
189
  lastSeen: opts.lastSeen ?? opts.connectedSince,
159
190
  connectedSince: opts.connectedSince,